添加cadfs_to_cdsl代码,优化cad生成

This commit is contained in:
2026-09-03 10:40:51 +08:00
parent f3eb5dff54
commit c25b73c71e
34 changed files with 3586 additions and 147 deletions
@@ -7,6 +7,7 @@ import json
import secrets
from typing import Any
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash
from app.cad_agent.application.llm_contracts import (
CandidateReview,
FinalReview,
@@ -55,6 +56,236 @@ class ActionCommandHandler:
available.append(atomic_id)
return tuple(available)
def schedule_next_feature(self, task_id: str) -> Accepted | Rejected:
"""Select the server-owned next ready node by fixed plan priority."""
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.SCHEDULING_FEATURE:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Feature scheduling is not expected in the current workflow state."))
plan = self._feature_plan(task_id, state)
if plan is None:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan is unavailable.", retryable=True))
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
if scheduler.all_done():
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
deterministic_failures = [
item for item in claim_results
if item.get("deterministic") and item.get("status") != "pass"
]
if deterministic_failures:
next_state = transition(
state,
"feature_replan",
error=ErrorCode.CLAIM_VERIFICATION_FAILED,
)
result = {
"status": "replan_required",
"phase": next_state.phase.value,
"claim_results": claim_results,
}
if not self.repository.compare_and_swap(next_state, events=[{
"event": "feature_plan_completion_failed",
"plan_hash": state.feature_plan_hash,
"revision_id": state.active_revision,
"claim_results": claim_results,
"failed_claim_ids": [str(item.get("claim_id") or "") for item in deterministic_failures],
"message": "All feature nodes completed, but final deterministic validation failed.",
}]):
return Rejected(self._stale())
return Accepted(result)
next_state = transition(state, "final_requested")
result = {"status": "final_validation", "phase": next_state.phase.value, "claim_results": claim_results}
if not self.repository.compare_and_swap(next_state, events=[{
"event": "feature_plan_complete",
"plan_hash": state.feature_plan_hash,
"revision_id": state.active_revision,
"claim_results": claim_results,
}], invocation_id=None, invocation_result=None):
return Rejected(self._stale())
return Accepted(result)
node = scheduler.next_ready()
if node is None:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan has no runnable node; revise its unresolved subgraph."))
contract = self.runtime.operation_contract(node.atomic_id)
requirement_ids = tuple(sorted(self._requirement_ids_for_claims(task_id, tuple(node.claim_ids))))
action_id = "feature_" + sha256(f"{state.feature_plan_hash}|{node.node_id}|{state.active_revision}".encode("utf-8")).hexdigest()[:16]
pending = PendingAction(
action_id=action_id,
working_head=state.working_head,
intent=node.intent,
requirement_ids=requirement_ids,
atomic_id=node.atomic_id,
expected_change=node.expected_change,
contract_hash=contract["contract_hash"],
idempotency_key=sha256(f"{task_id}|{action_id}|{state.working_head}".encode("utf-8")).hexdigest(),
node_id=node.node_id,
plan_hash=state.feature_plan_hash,
claim_ids=tuple(node.claim_ids),
depends_on_node_ids=tuple(node.depends_on),
)
next_state = transition(state, "feature_scheduled", pending_action=pending)
event = {
"event": "feature_node_scheduled", "node_id": node.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "action_id": action_id, "atomic_id": node.atomic_id,
"depends_on": node.depends_on, "claim_ids": node.claim_ids, "priority": node.priority,
}
if not self.repository.compare_and_swap(next_state, events=[event]):
return Rejected(self._stale())
return Accepted({"status": "scheduled", "node_id": node.node_id, "atomic_id": node.atomic_id, "phase": next_state.phase.value})
def submit_feature_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
action = state.pending_feature if state else None
if state is None or state.phase != TaskPhase.FEATURE_PENDING or action is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature fragment requires one scheduled feature node."))
plan = self._feature_plan(task_id, state)
if plan is None or action.plan_hash != state.feature_plan_hash:
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature belongs to an obsolete plan."))
node = next((item for item in plan.nodes if item.node_id == action.node_id), None)
if node is None or node.atomic_id != action.atomic_id or tuple(node.claim_ids) != action.claim_ids:
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature no longer matches the active plan."))
contract = self.runtime.operation_contract(action.atomic_id)
if contract["contract_hash"] != action.contract_hash:
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The operation contract changed while the feature was scheduled."))
base = self.artifacts.read_active_cdsl(task_id, state.active_revision)
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
references = self.runtime.reference_tokens(base)
selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {}
selector_kind = str(selector_policy.get("token_kind") or "")
allowed_selectors = [
token for token, value in selectors.items()
if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required"
and isinstance(value, dict) and value.get("kind") == selector_kind
]
errors = validate_fragment(contract, fragment, selector_tokens=allowed_selectors, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))
if errors:
return self._feature_failure(task_id, state, action, ErrorCode.AUTHOR_FORMAT_INVALID, "fragment_preflight", "CDSL fragment violates the scheduled operation schema.", invocation_id=invocation_id, field_errors=tuple(errors))
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
feature_ids = scheduler.feature_ids()
direct_feature_ids = tuple(feature_ids.get(dependency, "") for dependency in action.depends_on_node_ids)
if any(not value for value in direct_feature_ids):
return self._feature_failure(task_id, state, action, ErrorCode.RUNTIME_PRECONDITION_FAILED, "dependency", "A direct dependency has no verified runtime feature.", invocation_id=invocation_id)
fragment_hash = canonical_hash(fragment)
key = self._key(task_id, "feature_fragment", state.working_head, {"node_id": action.node_id, "fragment": fragment})
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
if invocation.status == "finished" and invocation.result is not None:
return Accepted(invocation.result)
try:
cdsl, audit = self.runtime.materialize_fragment(
base, fragment, contract, selectors, references,
require_through=self._claims_require_through(task_id, action.claim_ids),
depends_on_feature_ids=direct_feature_ids,
)
except Exception as error:
return self._feature_failure(task_id, state, action, self._runtime_error(error).code, "fragment_preflight", str(error), invocation_id=invocation_id, invocation=invocation)
stage_id = "feature_stage_" + sha256(key.encode("utf-8")).hexdigest()[:16]
try:
stage = self.artifacts.start_candidate_stage(task_id, key, {
"schema_version": "cad.v3.2.feature-input.v1", "stage_id": stage_id,
"node_id": action.node_id, "node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
"action_id": action.action_id, "fragment": fragment, "fragment_audit": audit,
})
except OSError as error:
return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event="feature_stage_storage_failure", message=str(error), details={"node_id": action.node_id})
building = transition(state, "feature_started", feature_stage_id=stage.stage_id)
if not self.repository.compare_and_swap(building, events=[{
"event": "feature_node_building", "node_id": action.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "stage_id": stage.stage_id, "fragment_hash": fragment_hash,
}]):
return Rejected(self._stale())
try:
rebuilt = self.runtime.build_checkpoint(cdsl, stage.output_dir, task_id, stage_id)
try:
preview = self.runtime.create_preview(stage.output_dir)
rebuilt["paths"]["glb"] = preview["path"]
except Exception as preview_error:
preview = {"preview_unavailable": str(preview_error)[:500]}
claim_results = self._evaluate_claims(task_id, rebuilt, claim_ids=set(action.claim_ids))
operation_results = self._operation_candidate_results(
action, contract, cdsl, rebuilt, parent_facts=self._facts(task_id, state.active_revision),
require_through=self._claims_require_through(task_id, action.claim_ids),
)
global_failures = [item for item in self._evaluate_claims(task_id, rebuilt) if item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} and item.get("status") != "pass"]
blockers = [
# A claim is assigned to exactly one runtime-atomic node in
# the Feature Plan. Unlike unassigned future-work claims,
# an assigned claim may not remain pending when that node is
# published: doing so would turn a missing feature into a
# permanent, apparently successful checkpoint.
*[item for item in claim_results if item.get("status") != "pass"],
*[item for item in operation_results if item.get("status") != "pass"],
*global_failures,
]
if blockers:
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", {
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
"claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers,
})
return self._feature_failure(task_id, building, action, ErrorCode.CLAIM_VERIFICATION_FAILED, "node_validation", "The feature did not satisfy its local deterministic acceptance.", invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id, "blockers": blockers})
verification = {
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
"node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
"feature_id": audit["assigned_feature_ids"][0], "claim_results": claim_results,
"operation_verifier_results": operation_results, "health": rebuilt["health"], "preview": preview,
}
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", verification)
revision_id = self._next_revision(task_id)
paths = self.artifacts.publish_candidate(task_id, stage.stage_id, revision_id)
next_state = transition(building, "feature_verified", active_revision=revision_id)
result = {"status": "verified", "node_id": action.node_id, "revision_id": revision_id, "paths": paths, "claim_results": claim_results}
event = {
"event": "feature_node_verified", "node_id": action.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "feature_id": audit["assigned_feature_ids"][0],
"atomic_id": action.atomic_id,
"revision_id": revision_id, "parent_revision": state.active_revision, "stage_id": stage.stage_id,
"claim_results": claim_results, "preview_unavailable": preview.get("preview_unavailable", ""),
}
if not self._commit_invocation(next_state, [event], invocation, result):
return Rejected(self._stale())
return Accepted(result)
except OSError as error:
return self._park_retry(building, ErrorCode.STORAGE_FAILURE, event="feature_build_storage_failure", message=str(error), details={"node_id": action.node_id, "stage_id": stage.stage_id})
except Exception as error:
return self._feature_failure(task_id, building, action, self._runtime_error(error).code, "engine_build", str(error), invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id})
def recover_feature_build(self, task_id: str) -> Accepted | Rejected:
"""Make a crashed build retryable without losing its scheduled node."""
state = self.repository.get_state(task_id)
action = state.pending_feature if state else None
if state is None or state.phase != TaskPhase.FEATURE_BUILDING or action is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable feature build."))
next_state = transition(state, "feature_retry")
if not self.repository.compare_and_swap(next_state, events=[{
"event": "feature_build_recovered", "node_id": action.node_id, "plan_hash": action.plan_hash,
"stage_id": state.feature_stage_id, "message": "Interrupted node build returned to the same scheduled feature.",
}]):
return Rejected(self._stale())
return Accepted({"status": "retry", "node_id": action.node_id, "phase": next_state.phase.value})
def _feature_failure(self, task_id: str, state: TaskState, action: PendingAction, code: ErrorCode, failure_class: str, message: str, *, invocation_id: str, field_errors: tuple[dict[str, Any], ...] = (), invocation: Any = None, details: dict[str, Any] | None = None) -> Rejected:
plan = self._feature_plan(task_id, state)
node = next((item for item in (plan.nodes if plan else []) if item.node_id == action.node_id), None)
if node is None:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature node is unavailable.", retryable=True))
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
attempt = scheduler.failure_count(action.node_id, failure_class) + 1
terminal = attempt >= 2
event = {
"event": "feature_node_failed", "node_id": action.node_id, "node_hash": node_hash(node),
"plan_hash": state.feature_plan_hash, "failure_class": failure_class, "attempt": attempt,
"terminal": terminal, "code": code.value, "normalized_error_code": code.value,
"atomic_id": action.atomic_id, "checkpoint_revision": state.active_revision,
"message": message[:1000], **(details or {}),
}
next_state = transition(state, "feature_replan" if terminal else "feature_retry", error=code)
result = {"status": "replan_required" if terminal else "retry", "node_id": action.node_id, "attempt": attempt, "failure_class": failure_class}
if invocation is not None:
committed = self._commit_invocation(next_state, [event], invocation, result)
else:
committed = self.repository.compare_and_swap(next_state, events=[event])
if not committed:
return Rejected(self._stale())
return Rejected(WorkflowError(code, message, field_errors=field_errors, details={**(details or {}), "node_id": action.node_id, "attempt": attempt, "replan_required": terminal}))
def propose_next_action(self, task_id: str, proposal: NextAction, *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
@@ -388,6 +619,8 @@ class ActionCommandHandler:
def submit_cdsl_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
if state is not None and state.phase == TaskPhase.FEATURE_PENDING:
return self.submit_feature_fragment(task_id, fragment, invocation_id=invocation_id)
action = state.pending_action if state else None
if state is None or state.phase != TaskPhase.ACTION_PENDING or action is None:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A CDSL fragment requires one pending action."))
@@ -922,11 +1155,18 @@ class ActionCommandHandler:
"atomic_id": atomic_id,
"fragment_hash": accepted_fragment,
}) if accepted_fragment and atomic_id else ""
next_state = transition(state, "final_repair", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED)
is_dag = bool(state.feature_plan_hash)
next_state = transition(
state,
"feature_replan" if is_dag else "final_repair",
repair_required=not is_dag,
error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED,
)
result = {"status": "repair", "revision_id": state.active_revision}
if not self._commit_invocation(next_state, [{
"event": "final_review_repair",
"event": "final_visual_reviewed" if is_dag else "final_review_repair",
"revision_id": state.active_revision,
**({"plan_hash": state.feature_plan_hash} if is_dag else {}),
"claim_results": claim_results,
"review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage],
"visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed],
@@ -947,15 +1187,48 @@ class ActionCommandHandler:
return Accepted(result)
next_state = transition(state, "final_accepted")
result = {"status": "completed", "revision_id": state.active_revision}
if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
if not self._commit_invocation(next_state, [{"event": "final_visual_reviewed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
def _evaluate_claims(self, task_id: str, facts: dict[str, Any]) -> list[dict[str, Any]]:
def _evaluate_claims(self, task_id: str, facts: dict[str, Any], *, claim_ids: set[str] | None = None) -> list[dict[str, Any]]:
contract = self._requirements_contract(task_id) or {}
claims = [claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict) for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
claims = [
claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and (claim_ids is None or str(claim.get("claim_id") or "") in claim_ids)
]
return self.verifiers.evaluate(claims, facts)
def _feature_plan(self, task_id: str, state: TaskState | None = None) -> FeaturePlan | None:
state = state or self.repository.get_state(task_id)
if state is None or not state.feature_plan_path:
return None
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
return FeaturePlan.model_validate(raw)
except ValueError:
return None
def _requirement_ids_for_claims(self, task_id: str, claim_ids: tuple[str, ...]) -> set[str]:
wanted = set(claim_ids)
contract = self._requirements_contract(task_id) or {}
return {
str(requirement.get("requirement_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
and any(str(claim.get("claim_id") or "") in wanted for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
}
def _claims_require_through(self, task_id: str, claim_ids: tuple[str, ...]) -> bool:
wanted = set(claim_ids)
contract = self._requirements_contract(task_id) or {}
return any(
str(claim.get("claim_id") or "") in wanted and claim.get("claim_kind") == "through_cylindrical_bore"
for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)
)
def claim_summary(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
"""Return bounded current claim facts for author context assembly."""
return [
@@ -1059,7 +1332,16 @@ class ActionCommandHandler:
continue
expected: dict[str, Any]
if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}:
diameter = params.get("diameter_mm")
# A counterbore can reuse an already-existing pilot bore. In
# that case the material change is the larger cylindrical
# recess, not an additional instance of the pilot diameter.
# Measuring the pilot would count the parent bore as a new
# feature and make a valid counterbore checkpoint fail.
diameter = (
params.get("counterbore_diameter_mm")
if action.atomic_id == "hole_counterbore" and claim_kind == "cylindrical_bore"
else params.get("diameter_mm")
)
positions = params.get("positions")
if not isinstance(diameter, (int, float)):
return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation has no measurable bore diameter"}}]
@@ -1152,7 +1434,7 @@ class ActionCommandHandler:
values = [
int(str(event.get("revision_id") or "").removeprefix("rev_"))
for event in self.repository.ledger_events(task_id)
if event.get("event") == "accepted"
if event.get("event") in {"accepted", "feature_node_verified"}
and str(event.get("revision_id") or "").startswith("rev_")
and str(event.get("revision_id") or "").removeprefix("rev_").isdigit()
]
@@ -18,6 +18,7 @@ from app.cad_agent.application.llm_contracts import (
stateless_final_review_schema,
stateless_next_action_schema,
)
from app.cad_agent.domain.feature_plan import FeaturePlan
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.ports import CadRuntime, ModelGateway
@@ -30,6 +31,8 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
if role == "reviewer":
return [
_tool("observe_images", ImageObservation.model_json_schema()),
# Compatibility conformance probe; regular DAG execution never
# calls a per-node reviewer.
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
_tool("review_final", stateless_final_review_schema(1)),
]
@@ -40,8 +43,9 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
# Compatibility probe only; production v3.2 workflow never exposes it.
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
_tool("propose_next_action", stateless_next_action_schema(atomic_ids)),
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
@@ -57,7 +61,7 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools}
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -9,6 +9,7 @@ import re
from typing import Any, Callable
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
from app.cad_agent.application.results import Accepted, Rejected
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
@@ -18,6 +19,9 @@ from app.cad_agent.ports import ArtifactStore, TaskRepository
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
_BORE_MARKERS = ("bore", "hole", "孔")
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
class RequirementsCommandHandler:
@@ -27,10 +31,11 @@ class RequirementsCommandHandler:
service derives those values strictly from the immutable checklist.
"""
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
self.repository = repository
self.artifacts = artifacts
self.registry = registry
self.atomic_ids = atomic_ids or (lambda: ())
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
@@ -59,6 +64,81 @@ class RequirementsCommandHandler:
len(self._checklist_items(task_id)),
)
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
"""Return the plan tool schema bound to the persisted planning state.
A model is allowed to choose node content, but it must not guess the
immutable lineage identifiers of a plan revision. Binding those values
as enums prevents a requirements-contract hash (or a stale plan hash)
from being mistaken for ``parent_plan_hash``.
"""
schema = FeaturePlan.model_json_schema()
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
node_properties = node.get("properties") if isinstance(node, dict) else None
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
if isinstance(atomic, dict):
atomic["enum"] = list(self.atomic_ids())
state = self.repository.get_state(task_id)
previous: FeaturePlan | None = None
if state is not None and state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
previous = None
parent_hash = plan_hash(previous) if previous is not None else ""
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
if isinstance(parent, dict):
parent["enum"] = [parent_hash]
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
if isinstance(replaced, dict):
replaced.update({
"type": "array",
"uniqueItems": True,
"minItems": len(replacements),
"maxItems": len(replacements),
"items": {"enum": replacements},
})
contract = self.artifacts.read_requirements_contract(
task_id,
state.requirements_contract_path if state is not None else "",
) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
if isinstance(node_claim_ids, dict):
# Authors occasionally repeat a visual claim on the node that
# creates the feature as well as in final_claim_ids. Accept that
# harmless reference at the tool boundary; submit_feature_plan()
# removes it before the immutable DAG is validated and written.
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
if isinstance(final_claim_ids, dict):
final_claim_ids["items"] = {"enum": visual_claim_ids}
final_claim_ids["maxItems"] = len(visual_claim_ids)
return schema
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
@@ -66,7 +146,157 @@ class RequirementsCommandHandler:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
# Compatibility shim for callers compiled against v3.1. The v3.2
# coordinator never offers this method to an LLM.
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
"""Freeze a validated initial plan or full subgraph plan revision."""
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
plan = self._normalize_feature_plan_visual_references(plan, contract)
plan = self._assign_unowned_global_health_claims(plan, contract)
previous: FeaturePlan | None = None
completed: dict[str, str] = {}
if state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
digest = plan_hash(plan)
path = f"plans/feature-plan-{digest}.json"
event = "feature_plan_written" if previous is None else "feature_plan_revised"
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
events: list[dict[str, Any]] = [{
"event": event,
"invocation_id": invocation_id,
"plan_path": written,
"plan_hash": digest,
"parent_plan_hash": plan.parent_plan_hash,
"replaces_node_ids": plan.replaces_node_ids,
}]
if previous is not None:
old_nodes = {node.node_id: node for node in previous.nodes}
old_hash = plan_hash(previous)
events.extend({
"event": "feature_node_invalidated",
"node_id": node_id,
"node_hash": node_hash(old_nodes[node_id]),
"plan_hash": old_hash,
"replacement_plan_hash": digest,
} for node_id in plan.replaces_node_ids)
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
@staticmethod
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Drop non-owning visual references from feature nodes.
A node's ``claim_ids`` drive synchronous deterministic acceptance.
Visual claims are owned solely by ``final_claim_ids`` and have no
node-local verifier. Retaining a repeated visual ID therefore adds
no behavior and turns an otherwise valid plan into a schema retry.
The contract validation below still requires every visual claim to be
present exactly once in ``final_claim_ids``.
"""
visual_claim_ids = {
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
}
if not visual_claim_ids or not any(
claim_id in visual_claim_ids
for node in plan.nodes
for claim_id in node.claim_ids
):
return plan
normalized = plan.model_copy(deep=True)
for node in normalized.nodes:
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
return normalized
@staticmethod
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Bind global solid-health claims to the unique root body feature.
``single_connected_body`` and ``solid_count_equals`` are checked as
global health on every feature checkpoint. When a plan has exactly
one root additive feature, their node owner is consequently
determined without choosing any geometry strategy. This prevents an
otherwise complete plan from failing merely because an author omitted
the redundant ownership annotation.
"""
claims = {
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
}
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
unowned = [
claim_id for claim_id, claim_kind in claims.items()
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
]
roots = [
node for node in plan.nodes
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
]
if not unowned or len(roots) != 1:
return plan
normalized = plan.model_copy(deep=True)
root_id = roots[0].node_id
for node in normalized.nodes:
if node.node_id == root_id:
node.claim_ids = [*node.claim_ids, *unowned]
break
return normalized
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
statuses = scheduler.statuses()
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
if not failed:
return set()
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
for node in plan.nodes:
for dependency in node.depends_on:
children.setdefault(dependency, set()).add(node.node_id)
result = set(failed)
pending = list(failed)
while pending:
current = pending.pop()
for child in children.get(current, set()):
if statuses.get(child) != "done" and child not in result:
result.add(child)
pending.append(child)
return result
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
@@ -79,7 +309,10 @@ class RequirementsCommandHandler:
if len(output.requirements) != len(targets):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
field_errors = self._claim_errors(normalized_output)
field_errors = [
*self._claim_errors(normalized_output),
*self._relationship_claim_errors(normalized_output, targets),
]
if field_errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
@@ -101,7 +334,7 @@ class RequirementsCommandHandler:
claim_position += 1
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
try:
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
@@ -176,12 +409,57 @@ class RequirementsCommandHandler:
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
return errors
@staticmethod
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
"""Require explicit coverage for an unambiguous centered-bore target.
The compiler remains free to choose claims for open-ended CAD prose.
A checklist item that explicitly says a bore is centered/concentric is
different: dropping that relationship leaves a measurable user fact
with no acceptance owner. The service only checks its presence here;
the registry validates its numeric parameters independently.
"""
errors: list[dict[str, str]] = []
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(
claim.claim_kind == "concentric_bore_to_outer_cylinder"
for claim in requirement.acceptance_claims
):
continue
errors.append({
"path": f"/requirements/{index}/acceptance_claims",
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
})
return errors
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
normalized = output.model_copy(deep=True)
warnings: list[str] = []
for target, requirement in zip(targets, normalized.requirements, strict=True):
normalized_claims = []
for claim in requirement.acceptance_claims:
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
warnings.append(
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_unbacked_coaxial_bore_group(normalized, claim):
warnings.append(
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
warnings.append(
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
@@ -202,8 +480,104 @@ class RequirementsCommandHandler:
"claim_kind": "visual",
"expected": {"description": target[:360]},
})]
self._derive_centered_bore_claims(normalized, targets)
return normalized, list(dict.fromkeys(warnings))
@staticmethod
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
"""Keep a circular-hole verifier from accepting or rejecting a slot.
``rectangular_corner_through_bore_pattern`` measures four complete
cylindrical bores at equal edge offsets. An obround slot has two arc
ends and straight flanks; treating its stated length as an edge offset
produces an unsatisfiable contract even when the CAD is correct.
"""
return (
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
)
@staticmethod
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
"""Attach a measurable concentricity claim when its inputs are frozen.
The requirements compiler receives a Markdown checklist, not runtime
geometry IDs. Once it has already compiled an external cylindrical
diameter and an explicitly centred bore diameter, their relationship
is a service-owned mechanical consequence. Requiring an author to
remember the internal verifier name makes a complete user request
fail for a bookkeeping omission rather than a CAD decision.
"""
outer_diameters: list[float] = []
for requirement in output.requirements:
for claim in requirement.acceptance_claims:
expected = claim.expected
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
outer_diameters.append(float(diameter))
if not outer_diameters:
return
outer_diameter = max(outer_diameters)
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
for target, requirement in zip(targets, output.requirements, strict=True):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
continue
bore_diameter = next((
float(claim.expected["diameter_mm"])
for claim in requirement.acceptance_claims
if claim.claim_kind in bore_claim_kinds
and isinstance(claim.expected, dict)
and isinstance(claim.expected.get("diameter_mm"), (int, float))
and float(claim.expected["diameter_mm"]) > 0
), None)
if bore_diameter is None:
continue
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
"claim_kind": "concentric_bore_to_outer_cylinder",
"expected": {
"bore_diameter_mm": bore_diameter,
"outer_diameter_mm": outer_diameter,
"tolerance_mm": 0.01,
},
}))
@staticmethod
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
"""Reject a bore-group verifier when the contract has no such group.
``coaxial_through_bore_group`` measures multiple inner bores of one
diameter. It cannot prove a lone central bore is concentric with an
external cylindrical wall. This is a mechanical consistency check:
some through-bore claim must request at least the group count.
"""
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
return False
expected = getattr(claim, "expected", {})
if not isinstance(expected, dict):
return True
diameter = expected.get("diameter_mm")
count = expected.get("count")
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
return True
for requirement in output.requirements:
for candidate in requirement.acceptance_claims:
candidate_expected = getattr(candidate, "expected", {})
if (
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
and isinstance(candidate_expected, dict)
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
and isinstance(candidate_expected.get("count"), int)
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
and int(candidate_expected["count"]) >= count
):
return False
return True
@staticmethod
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
+502 -18
View File
@@ -1,8 +1,8 @@
"""LLM turn coordinator for protocol v3.
It selects only which schema is visible from the persisted state. It never
derives a CAD operation from requirements; that choice is always an author
tool call handled by ``ActionCommandHandler``.
It selects only the structured schema visible from persisted state. Feature
selection is server-owned: the scheduler chooses one atomic DAG node and the
author can submit only that node's fragment.
"""
from __future__ import annotations
@@ -29,6 +29,7 @@ from app.cad_agent.application.llm_contracts import (
)
from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition
@@ -36,6 +37,7 @@ from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, M
T = TypeVar("T", bound=BaseModel)
_FEATURE_REPLAN_FAILURE_LIMIT = 3
@dataclass(frozen=True, slots=True)
@@ -248,6 +250,13 @@ class WorkflowCoordinator:
if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}:
yield "task_terminal", self._projected_terminal(task_id, state)
return
if state.phase == TaskPhase.FEATURE_BUILDING:
recovered = self.actions.recover_feature_build(task_id)
yield "feature_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
if isinstance(recovered, Rejected):
yield self._service_failure(task_id, state, recovered.error)
return
continue
if state.phase == TaskPhase.CANDIDATE_BUILDING:
recovered = self.actions.recover_candidate_build(task_id)
yield "candidate_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
@@ -434,6 +443,130 @@ class WorkflowCoordinator:
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
exhausted = self._feature_replan_exhausted(task_id, state)
if exhausted is not None:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
"event": "feature_plan_no_progress_limit",
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same atomic feature exhausted its cross-plan replan budget.",
"checkpoint_preserved": bool(state.active_revision),
"revision_id": state.active_revision,
**exhausted,
}])
yield "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"revisionId": state.active_revision,
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same atomic feature repeatedly failed after local replanning; the last executable checkpoint remains available.",
}
return
plan_schema = self.requirements.feature_plan_schema(task_id)
tools = [self._tool("write_feature_plan", plan_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "write_feature_plan", result, format_errors, feedback)
yield "feature_plan", {"taskId": task_id, "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
validation = canonical_validate(raw, FeaturePlan)
dynamic_error = canonical_validate_schema(raw, plan_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "feature_plan", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.requirements.submit_feature_plan(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="write_feature_plan")
yield "feature_plan", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "feature_plan_ready", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.SCHEDULING_FEATURE:
command = self.actions.schedule_next_feature(task_id)
if isinstance(command, Rejected):
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": command.error.code.value, "message": command.error.message}
return
yield "feature_scheduled", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
if state.phase == TaskPhase.FEATURE_PENDING:
observed = action_observations.setdefault(state.working_head, set())
tools = self._action_tools(task_id, state, observed)
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "feature_result", {"taskId": task_id, "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
if name == "inspect_topology":
validation = canonical_validate(raw, StatelessTopologyRequest)
if isinstance(validation, WorkflowError):
yield "feature_result", self._event(task_id, name, validation.payload(), "error", usage)
continue
payload = self._topology_payload(task_id, state, validation.kind, validation.limit)
observed.add("topology")
yield "tool_call", self._event(task_id, name, payload, "success", usage)
feedback = [{"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}]
continue
fragment = canonical_json_object(raw)
if isinstance(fragment, WorkflowError):
yield "feature_result", self._event(task_id, name, fragment.payload(), "error", usage)
feedback = [self._feedback(fragment)]
continue
command = self.actions.submit_feature_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
after = self.repository.get_state(task_id)
yield "feature_result", self._event(task_id, name, command.error.payload(), "error", usage)
if after is not None and after.version != state.version:
feedback = [self._feedback(command.error)]
continue
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
if terminal:
yield terminal
return
continue
yield "feature_result", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.AWAITING_ACTION:
contract = self._requirements_contract(task_id, state) or {}
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
@@ -688,6 +821,14 @@ class WorkflowCoordinator:
yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
if state.phase == TaskPhase.FINAL_VALIDATION:
facts = self.actions._facts(task_id, state.active_revision)
if not (facts.get("report") or {}).get("render_manifest"):
try:
self.runtime.render_review_bundle(str(self.artifacts.artifact_path(task_id, f"revisions/{state.active_revision}")))
except Exception as error:
yield self._service_failure(task_id, state, WorkflowError(ErrorCode.RENDER_SERVICE_UNAVAILABLE, str(error)[:1000], retryable=True))
return
yield "final_render_ready", {"taskId": task_id, "revisionId": state.active_revision, "status": "success"}
recovered = self.actions.recover_final_review(task_id)
if recovered is not None:
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
@@ -768,6 +909,15 @@ class WorkflowCoordinator:
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
state = self.repository.get_state(task_id)
if state is not None and state.feature_plan_hash:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
"event": "feature_plan_no_progress_limit", "code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The feature DAG reached its bounded turn limit.",
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
}])
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.NO_PROGRESS_LIMIT.value, "message": "The feature DAG reached its bounded turn limit; the last executable checkpoint remains available."}
return
terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
@@ -781,6 +931,14 @@ class WorkflowCoordinator:
yield self._storage_failure(task_id, str(error))
except Exception as error:
state = self.repository.get_state(task_id)
if state is not None and state.feature_plan_hash and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{
"event": "feature_plan_internal_failure", "message": str(error)[:1000],
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
}])
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]}
return
terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
@@ -814,6 +972,19 @@ class WorkflowCoordinator:
return None
details = budget.payload()
details["next_actor"] = actor
if state.feature_plan_hash:
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
self.repository.compare_and_swap(failed, events=[{
"event": "call_budget_exhausted", "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
"message": "Configured model-call budget is exhausted before the feature DAG converged.",
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, **details,
}])
return "task_terminal", {
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
"message": "Configured model-call budget is exhausted before the feature DAG converged; the last executable checkpoint remains available.",
"budget": details, "blockerType": "call_budget_exhausted", "userActionRequired": False,
}
terminal = self._best_effort_terminal(
task_id,
state,
@@ -1235,7 +1406,7 @@ class WorkflowCoordinator:
return []
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"user_clarifications": self._user_clarifications(task_id),
@@ -1246,31 +1417,85 @@ class WorkflowCoordinator:
}
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.",
}
elif state.phase == TaskPhase.COMPILING_REQUIREMENTS:
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"verifier_registry": self.requirements.registry.expected_one_of_schema(),
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.",
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance. An obround, slotted, or long-slot feature is not a rectangular_corner_through_bore_pattern: keep it visual unless a dedicated slot verifier is available. Every explicit centered, concentric, or coaxial bore must have concentric_bore_to_outer_cylinder coverage. coaxial_through_bore_group is only for two or more same-diameter inner bores. For one central bore concentric with an external cylinder, use concentric_bore_to_outer_cylinder with bore_diameter_mm and outer_diameter_mm.",
}
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
elif state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
active_plan = self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None
contract = self._requirements_contract(task_id, state) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
required_replacements: list[str] = []
parent_plan_hash = ""
if isinstance(active_plan, dict):
try:
parsed_plan = FeaturePlan.model_validate(active_plan)
parent_plan_hash = plan_hash(parsed_plan)
required_replacements = sorted(self.requirements._required_replacements(parsed_plan, task_id))
except ValueError:
pass
content = {
"protocol": "cad.v3.1.markdown-first",
"protocol": "cad.v3.2.feature-dag",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"compiled_contract": self._requirements_contract(task_id, state),
"instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.",
"compiled_contract": contract,
"claim_ownership_binding": {
"node_claim_ids_must_be_drawn_only_from": deterministic_claim_ids,
"final_claim_ids_must_equal": visual_claim_ids,
"visual_only_nodes_must_use_empty_claim_ids": True,
},
"supported_atomic_ids": list(self.runtime.supported_atomic_ids()),
"active_feature_plan": active_plan,
"plan_lineage_binding": {
"parent_plan_hash": parent_plan_hash,
"replaces_node_ids": required_replacements,
},
"feature_node_statuses": self._feature_node_statuses(task_id, active_plan),
"replanning_evidence": self._replanning_evidence(task_id, state),
"instruction": (
"Write the complete feature-plan JSON. Each node is exactly one runtime atomic feature. "
"Every deterministic frozen claim must belong to exactly one node; every visual claim must be in final_claim_ids. "
"Never place a visual claim in any node claim_ids. Nodes for ribs, chamfers, slots, or other visual-only work are valid with claim_ids: []. "
"Use dependency edges only for direct geometric prerequisites and unique fixed priorities. "
"The tool schema binds parent_plan_hash and replaces_node_ids to the service values: copy those exact values, never use the requirements-contract hash. "
"For a revision, preserve completed nodes byte-for-byte and use new IDs for replacements."
),
}
else:
contract = self._requirements_contract(task_id, state) or {}
action = state.pending_feature
feature_node_turn = state.phase == TaskPhase.FEATURE_PENDING and action is not None
compact = [{
"requirement_id": item.get("requirement_id"),
"statement": item.get("statement"),
"acceptance_claims": [
{
@@ -1281,14 +1506,20 @@ class WorkflowCoordinator:
if isinstance(claim, dict)
],
} for item in contract.get("requirements") or () if isinstance(item, dict)]
action = state.pending_action
if feature_node_turn:
owned_requirements = set(action.requirement_ids)
compact = [
item for item in compact
if str(item.get("requirement_id") or "") in owned_requirements
]
operation = self.runtime.operation_contract(action.atomic_id) if action is not None else None
selector_shape = str((operation or {}).get("fragment_shape", {}).get("selector_tokens") or "forbidden")
instruction = "Choose only the next atomically verifiable action or complete when every requirement is proven. The service will not choose CAD operations for you."
instruction = "The service has scheduled one atomic feature from the immutable DAG. Submit exactly that feature's CDSL fragment."
if state.repair_required:
instruction = "A prior candidate or final review requires repair. Use the current server evidence to record a geometry conclusion, then either choose a new action or, after a rollback conclusion, request an earlier checkpoint. The service will not choose CAD operations for you."
operation_payload = self._operation_payload(task_id, state) if action is not None else None
selector_summary = []
sketch_workplane_candidates = []
if action is not None and selector_shape == "required":
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
allowed = set(self._selector_tokens_for_contract(operation or {}, tokens))
@@ -1297,7 +1528,35 @@ class WorkflowCoordinator:
for token, value in tokens.items() if token in allowed
][:16]
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
content = {"protocol": "cad.v3.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
if (
feature_node_turn
and state.active_revision
and str((operation or {}).get("fragment_shape", {}).get("sketch") or "forbidden") == "required"
):
sketch_workplane_candidates = self._sketch_workplane_candidates(
self.artifacts.read_topology(task_id, state.active_revision)
)
if sketch_workplane_candidates:
instruction += (
" sketch_workplane_candidates are measured planar material faces. "
"For a blind cut, select a plane whose material region covers the intended profile; "
"do not choose a newer or higher face merely because it is the last feature."
)
current_claims = self.actions.claim_summary(task_id, state)
if feature_node_turn:
current_claims = [
item for item in current_claims
if str(item.get("claim_id") or "") in set(action.claim_ids)
]
content = {"protocol": "cad.v3.2.feature-dag", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in current_claims], "model_summary": self.actions.model_summary(task_id, state), "pending_feature": self._public_pending_context(state), "direct_upstream_facts": self._direct_upstream_facts(task_id, state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "sketch_workplane_candidates": sketch_workplane_candidates, "recent_failures": self._recent_failure_constraints(task_id, state), "instruction": instruction}
if not feature_node_turn:
content.update({
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"feature_plan": self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None,
"feature_plan_hash": state.feature_plan_hash,
"feature_node_statuses": self._feature_node_statuses(task_id, None),
})
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
return [*messages, *feedback[-2:]]
@@ -1322,6 +1581,160 @@ class WorkflowCoordinator:
except (OSError, ValueError):
return ""
def _feature_node_statuses(self, task_id: str, plan_payload: dict[str, Any] | None) -> dict[str, str]:
try:
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
state = self.repository.get_state(task_id)
plan = FeaturePlan.model_validate(plan_payload) if isinstance(plan_payload, dict) else FeaturePlan.model_validate(self.artifacts.read_json(task_id, state.feature_plan_path) if state and state.feature_plan_path else None)
return FeatureScheduler(plan, self.repository.ledger_events(task_id)).statuses()
except (ValueError, TypeError, OSError):
return {}
def _direct_upstream_facts(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
"""Project only verified direct dependencies for a scheduled node.
The current model summary describes the working head. This additional
projection lets the author distinguish the exact prerequisite nodes
without exposing arbitrary historical topology or old failed stages.
"""
pending = state.pending_feature
if pending is None or not pending.depends_on_node_ids:
return []
verified: dict[str, dict[str, Any]] = {}
for event in self.repository.ledger_events(task_id):
if event.get("event") == "feature_node_verified":
node_id = str(event.get("node_id") or "")
if node_id in pending.depends_on_node_ids:
verified[node_id] = event
result: list[dict[str, Any]] = []
for node_id in pending.depends_on_node_ids:
event = verified.get(node_id)
if event is None:
continue
revision_id = str(event.get("revision_id") or "")
verification = self.artifacts.read_json(task_id, f"revisions/{revision_id}/node-verification.json") if revision_id else None
evidence = verification if isinstance(verification, dict) else {}
result.append({
"node_id": node_id,
"revision_id": revision_id,
"feature_id": str(event.get("feature_id") or evidence.get("feature_id") or ""),
"claim_results": [
{
"claim_id": str(item.get("claim_id") or ""),
"status": str(item.get("status") or ""),
"claim_kind": str(item.get("claim_kind") or ""),
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
}
for item in evidence.get("claim_results") or ()
if isinstance(item, dict)
],
"operation_verifier_results": [
{
"claim_kind": str(item.get("claim_kind") or ""),
"status": str(item.get("status") or ""),
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
}
for item in evidence.get("operation_verifier_results") or ()
if isinstance(item, dict)
],
"health": evidence.get("health") if isinstance(evidence.get("health"), dict) else {},
})
return result
@staticmethod
def _sketch_workplane_candidates(topology: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]:
"""Expose compact, measured planes for sketch operations without selectors.
Sketch extrusions deliberately do not use B-rep selector tokens. The
author still needs enough geometry to choose a material face instead of
placing a profile on the most recently created boss.
"""
records = topology.get("records") if isinstance(topology, dict) else None
if not isinstance(records, list):
return []
candidates: list[dict[str, Any]] = []
seen: set[tuple[float, ...]] = set()
for record in records:
geometry = record.get("geometry") if isinstance(record, dict) else None
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
normal = geometry.get("normal") if isinstance(geometry, dict) else None
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
if (
not isinstance(geometry, dict)
or geometry.get("surface_type") != "plane"
or not isinstance(center, list)
or not isinstance(normal, list)
or len(center) != 3
or len(normal) != 3
or not isinstance(bbox, list)
or len(bbox) != 6
):
continue
try:
point = [round(float(value), 4) for value in center]
direction = [round(float(value), 4) for value in normal]
bounds = [round(float(value), 4) for value in bbox]
except (TypeError, ValueError):
continue
key = tuple(direction + point + bounds)
if key in seen:
continue
seen.add(key)
footprint = abs((bounds[3] - bounds[0]) * (bounds[4] - bounds[1]))
candidates.append({
"point_mm": point,
"normal": direction,
"bbox_mm": bounds,
"footprint_bbox_area_mm2": round(footprint, 4),
})
# Favor outward horizontal faces, then the broadest support surface.
# A base top commonly supports an outer slot while a taller boss does
# not; ordering by Z would teach the opposite choice.
candidates.sort(key=lambda item: (
-float(item["normal"][2]),
-float(item["footprint_bbox_area_mm2"]),
-float(item["point_mm"][2]),
item["bbox_mm"],
))
return candidates[:limit]
def _replanning_evidence(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
"""Provide bounded server evidence that led to this local replan."""
evidence: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
if event.get("plan_hash") != state.feature_plan_hash:
continue
if event.get("event") == "feature_node_failed":
evidence.append({
"kind": "node_failure",
"node_id": str(event.get("node_id") or ""),
"failure_class": str(event.get("failure_class") or ""),
"attempt": int(event.get("attempt") or 0),
"message": str(event.get("message") or "")[:500],
"blockers": (event.get("blockers") or [])[:8] if isinstance(event.get("blockers"), list) else [],
})
elif event.get("event") == "final_visual_reviewed" and event.get("visual_not_passed"):
evidence.append({
"kind": "final_visual_review",
"claim_ids": [str(value) for value in event.get("visual_not_passed") or () if str(value)],
"issues": [str(value)[:500] for value in event.get("issues") or () if str(value)],
"evidence": [str(value)[:500] for value in event.get("evidence") or () if str(value)],
})
elif event.get("event") == "feature_plan_completion_failed":
evidence.append({
"kind": "final_deterministic_validation",
"claim_ids": [str(value) for value in event.get("failed_claim_ids") or () if str(value)],
"message": str(event.get("message") or "")[:500],
"claim_results": [
self._public_claim_result(item)
for item in event.get("claim_results") or ()
if isinstance(item, dict) and item.get("deterministic") and item.get("status") != "pass"
],
})
if len(evidence) == 8:
break
return evidence
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
"""Expose the persisted requirement question when a task is parked.
@@ -1419,7 +1832,15 @@ class WorkflowCoordinator:
@staticmethod
def _public_pending_context(state: TaskState) -> dict[str, Any] | None:
action = state.pending_action
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
return {
"node_id": action.node_id,
"plan_hash": action.plan_hash,
"intent": action.intent,
"operation": action.atomic_id,
"expected_change": action.expected_change,
"claim_ids": list(action.claim_ids),
"depends_on_node_ids": list(action.depends_on_node_ids),
} if action else None
@staticmethod
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
@@ -1471,13 +1892,20 @@ class WorkflowCoordinator:
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
constraints: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
if event.get("checkpoint_revision") != state.active_revision:
is_current_feature_failure = (
event.get("event") == "feature_node_failed"
and event.get("plan_hash") == state.feature_plan_hash
and state.pending_feature is not None
and event.get("node_id") == state.pending_feature.node_id
)
if not is_current_feature_failure and event.get("checkpoint_revision") != state.active_revision:
continue
if not event.get("normalized_error_code"):
normalized_error_code = str(event.get("normalized_error_code") or event.get("code") or "")
if not normalized_error_code:
continue
constraints.append({
"atomic_id": str(event.get("atomic_id") or ""),
"normalized_error_code": str(event.get("normalized_error_code") or ""),
"normalized_error_code": normalized_error_code,
"fragment_hash": str(event.get("fragment_hash") or ""),
"prohibited_exact_fingerprint": str(event.get("failure_exact_fingerprint") or ""),
"attempt": int(event.get("attempt") or 1),
@@ -1487,6 +1915,42 @@ class WorkflowCoordinator:
break
return constraints
def _feature_replan_exhausted(self, task_id: str, state: TaskState) -> dict[str, Any] | None:
"""Bound repeated replacement plans at one immutable checkpoint.
Node IDs must change on every subgraph revision, so a node-local retry
counter alone cannot stop a planner from replacing the same failed
atomic operation forever. A successful feature creates a new revision;
therefore checkpoint + atomic operation is a stable, narrow boundary
for this cross-plan budget.
"""
if state.phase != TaskPhase.REPLANNING_FEATURE_SUBGRAPH:
return None
terminal = [
event for event in self.repository.ledger_events(task_id)
if event.get("event") == "feature_node_failed"
and bool(event.get("terminal"))
and str(event.get("checkpoint_revision") or "") == state.active_revision
]
if not terminal:
return None
atomic_id = str(terminal[-1].get("atomic_id") or "")
if not atomic_id:
return None
matching = [
event for event in terminal
if str(event.get("atomic_id") or "") == atomic_id
]
if len(matching) < _FEATURE_REPLAN_FAILURE_LIMIT:
return None
return {
"atomic_id": atomic_id,
"checkpoint_revision": state.active_revision,
"terminal_failure_count": len(matching),
"limit": _FEATURE_REPLAN_FAILURE_LIMIT,
"node_ids": [str(event.get("node_id") or "") for event in matching],
}
@staticmethod
def _selector_tokens_for_contract(contract: dict[str, Any], tokens: dict[str, dict[str, Any]]) -> list[str]:
"""Narrow dynamic selector enums to the current contract's kind."""
@@ -1697,6 +2161,18 @@ class WorkflowCoordinator:
feedback[:] = [self._feedback(error)]
if counters[name] < self.config.format_error_limit:
return None
if state.feature_plan_hash:
failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT)
self.repository.compare_and_swap(failed, events=[{
"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors),
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
}])
return "task_terminal", {
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
"code": ErrorCode.FAILED_AUTHOR_FORMAT.value,
"message": f"{actor.capitalize()} repeatedly failed the canonical schema; the last executable checkpoint remains available.",
"tool": name, "field_errors": list(error.field_errors),
}
terminal = self._best_effort_terminal(
task_id,
state,
@@ -1760,6 +2236,14 @@ class WorkflowCoordinator:
) -> tuple[str, dict[str, Any]] | None:
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
return self._service_failure(task_id, state, error)
if tool == "write_feature_plan":
# A plan revision is authored against an already-frozen contract.
# Its field errors must use the plan tool's own retry budget, not
# the requirements compiler's shared counter. Otherwise one
# earlier requirements correction can make the first replan
# attempt terminal, even though the state remains perfectly
# recoverable in REPLANNING_FEATURE_SUBGRAPH.
return self._format_failure(task_id, state, tool, error, counters, feedback)
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
@staticmethod