添加cadfs_to_cdsl代码,优化cad生成
This commit is contained in:
@@ -124,7 +124,7 @@ class ProfileCadRuntime:
|
||||
result["ref_" + sha256(f"{document_hash}|{feature_id}".encode("utf-8")).hexdigest()[:16]] = feature_id
|
||||
return result
|
||||
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
# ActionCommandHandler validates the exposed schema first, but this
|
||||
# adapter is also used during crash recovery. Keep the runtime boundary
|
||||
# self-contained so a corrupted/replayed staged payload cannot produce
|
||||
@@ -168,7 +168,24 @@ class ProfileCadRuntime:
|
||||
if not isinstance(supplied, list) or not all(token in reference_tokens for token in supplied):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference token is absent or stale")
|
||||
materialized["feature"]["params"][slot] = [reference_tokens[token] for token in supplied]
|
||||
self._semantic_preflight(materialized, contract, selector_tokens, base_cdsl, require_through=require_through)
|
||||
through_normalizations = self._normalize_required_through_cut_depth(
|
||||
materialized,
|
||||
contract,
|
||||
selector_tokens,
|
||||
require_through=require_through,
|
||||
)
|
||||
cut_support_normal = self._semantic_preflight(
|
||||
materialized,
|
||||
contract,
|
||||
selector_tokens,
|
||||
base_cdsl,
|
||||
require_through=require_through,
|
||||
)
|
||||
direction_normalizations = self._normalize_extrude_cut_direction(
|
||||
materialized,
|
||||
contract,
|
||||
cut_support_normal,
|
||||
)
|
||||
document = deepcopy(base_cdsl) if isinstance(base_cdsl, dict) else {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [],
|
||||
}
|
||||
@@ -177,9 +194,17 @@ class ProfileCadRuntime:
|
||||
features = document.setdefault("features", [])
|
||||
if not isinstance(sketches, list) or not isinstance(features, list):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: base CDSL collections are invalid")
|
||||
existing_feature_ids = {
|
||||
str(item.get("id") or "")
|
||||
for item in features
|
||||
if isinstance(item, dict) and str(item.get("id") or "")
|
||||
}
|
||||
direct_dependencies = tuple(str(value) for value in depends_on_feature_ids)
|
||||
if len(direct_dependencies) != len(set(direct_dependencies)) or any(value not in existing_feature_ids for value in direct_dependencies):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: plan dependency feature is absent from the active checkpoint")
|
||||
index = len(features) + 1
|
||||
feature = materialized["feature"]
|
||||
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": [str(features[-1].get("id"))] if features else []}
|
||||
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": list(direct_dependencies)}
|
||||
if contract["fragment_shape"]["sketch"] == "required":
|
||||
sketch_id = f"sketch_{len(sketches) + 1:03d}"
|
||||
sketch = materialized["sketch"]
|
||||
@@ -225,35 +250,69 @@ class ProfileCadRuntime:
|
||||
"RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: "
|
||||
+ message
|
||||
) from error
|
||||
return document, {"schema_version": "cad.v3.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else []}
|
||||
return document, {"schema_version": "cad.v3.2.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "depends_on_feature_ids": list(direct_dependencies), "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else [], "server_normalizations": [*through_normalizations, *direction_normalizations]}
|
||||
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
"""Build the exact geometry checkpoint required by every DAG node.
|
||||
|
||||
Rendering is deliberately absent here: a renderer outage must never
|
||||
make a geometrically valid atomic feature fail its local acceptance.
|
||||
"""
|
||||
root = Path(output_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
cdsl_copy = deepcopy(cdsl)
|
||||
cdsl_copy["part_id"] = task_id
|
||||
step_path = root / "model.step"
|
||||
glb_path = root / "model.glb"
|
||||
self._write_json(root / "model.cdsl.json", cdsl_copy)
|
||||
try:
|
||||
self._validate_finite_tree(cdsl_copy)
|
||||
validate_cdsl(cdsl_copy, self.engine)
|
||||
except RuntimeAdapterError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: persisted CDSL failed rebuild preflight: {error}") from error
|
||||
try:
|
||||
engine_result = self.engine.run_cdsl_only(cdsl_copy, step_path)
|
||||
if str(engine_result.get("engine") or "") != "cdsl_only":
|
||||
raise RuntimeAdapterError("engine did not execute CDSL-only rebuild")
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
health = self._health(engine_result, step_path, glb_path)
|
||||
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id, preview=preview)
|
||||
health = self._health(engine_result, step_path)
|
||||
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id)
|
||||
self._write_json(root / "model.topology.json", topology)
|
||||
manifest = render_checkpoint(self.settings, step_path=step_path, output_dir=root / "renders")
|
||||
report = {"engine_result": engine_result, "preview": preview, "health": health, "render_manifest": manifest}
|
||||
report = {"engine_result": engine_result, "preview": {}, "health": health, "render_manifest": {}}
|
||||
self._write_json(root / "rebuild-report.json", report)
|
||||
return {"health": health, "topology": topology, "report": report, "render_manifest": manifest, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json", "render_manifest": "renders/render-manifest.json"}}
|
||||
return {"health": health, "topology": topology, "report": report, "render_manifest": {}, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "topology": "model.topology.json", "report": "rebuild-report.json"}}
|
||||
except OSError:
|
||||
raise
|
||||
except RuntimeAdapterError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
|
||||
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]:
|
||||
"""Create an optional GLB preview for a completed checkpoint."""
|
||||
root = Path(output_dir)
|
||||
step_path = root / "model.step"
|
||||
glb_path = root / "model.glb"
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
report_path = root / "rebuild-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
|
||||
report["preview"] = preview
|
||||
self._write_json(report_path, report)
|
||||
return {"preview": preview, "path": "model.glb"}
|
||||
|
||||
def render_review_bundle(self, output_dir: str) -> dict[str, Any]:
|
||||
root = Path(output_dir)
|
||||
manifest = render_checkpoint(self.settings, step_path=root / "model.step", output_dir=root / "renders")
|
||||
report_path = root / "rebuild-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
|
||||
report["render_manifest"] = manifest
|
||||
self._write_json(report_path, report)
|
||||
return manifest
|
||||
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
# Legacy v3.1 compatibility path. New DAG nodes use build_checkpoint.
|
||||
built = self.build_checkpoint(cdsl, output_dir, task_id, revision_id)
|
||||
root = Path(output_dir)
|
||||
try:
|
||||
preview = self.create_preview(output_dir)
|
||||
manifest = self.render_review_bundle(output_dir)
|
||||
report = json.loads((root / "rebuild-report.json").read_text(encoding="utf-8"))
|
||||
return {**built, "report": report, "preview": preview["preview"], "render_manifest": manifest, "paths": {**built["paths"], "glb": "model.glb", "render_manifest": "renders/render-manifest.json"}}
|
||||
except OSError:
|
||||
# Artifact writes are a recoverable infrastructure outage. Let the
|
||||
# application handler park the same candidate stage for replay.
|
||||
@@ -330,7 +389,7 @@ class ProfileCadRuntime:
|
||||
}
|
||||
return document
|
||||
|
||||
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None:
|
||||
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> list[float] | None:
|
||||
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
|
||||
policy = contract["selector_policy"]
|
||||
@@ -340,6 +399,83 @@ class ProfileCadRuntime:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selector token is absent, stale, or has the wrong kind")
|
||||
for name in contract["semantic_preflight"]:
|
||||
self._semantic_preflight_handlers[name](fragment, selector_tokens, base_cdsl, require_through)
|
||||
if contract.get("atomic_id") == "extrude_cut_blind":
|
||||
return self._preflight_extrude_cut_contacts_material(fragment, selector_tokens)
|
||||
return None
|
||||
|
||||
def _normalize_extrude_cut_direction(
|
||||
self,
|
||||
fragment: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
support_normal: list[float] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Aim a surface-attached cut into the measured material half-space.
|
||||
|
||||
A sketch extrusion has no host selector. Once preflight proves that its
|
||||
profile lies on an oriented boundary face, the only executable blind
|
||||
cut direction is the material side of that face. This is a coordinate
|
||||
normalization, not a planning decision, and is recorded in the audit.
|
||||
"""
|
||||
if contract.get("atomic_id") != "extrude_cut_blind" or not self._valid_vector3(support_normal, require_nonzero=True):
|
||||
return []
|
||||
sketch = fragment.get("sketch") if isinstance(fragment, dict) else None
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
params = fragment.get("feature", {}).get("params") if isinstance(fragment.get("feature"), dict) else None
|
||||
if not self._valid_vector3(normal, require_nonzero=True) or not isinstance(params, dict):
|
||||
return []
|
||||
unit_normal = [float(component) / self._norm(normal) for component in normal]
|
||||
unit_support = [float(component) / self._norm(support_normal) for component in support_normal]
|
||||
alignment = self._dot(unit_normal, unit_support)
|
||||
if abs(abs(alignment) - 1.0) > 1e-6:
|
||||
return []
|
||||
materialized_reverse = alignment > 0
|
||||
submitted_reverse = bool(params.get("reverse", False))
|
||||
params["reverse"] = materialized_reverse
|
||||
return [{
|
||||
"path": "feature.params.reverse",
|
||||
"submitted": submitted_reverse,
|
||||
"materialized": materialized_reverse,
|
||||
"reason": "surface-attached cut must travel into the measured material half-space",
|
||||
"support_normal": unit_support,
|
||||
}]
|
||||
|
||||
def _normalize_required_through_cut_depth(
|
||||
self,
|
||||
fragment: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
selector_tokens: dict[str, dict[str, Any]],
|
||||
*,
|
||||
require_through: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add the minimum deterministic exit allowance for a through cut.
|
||||
|
||||
The author owns nominal feature geometry. For a through requirement,
|
||||
however, the runtime owns the executable end condition: this engine
|
||||
needs a strictly greater cut distance than the measured host span.
|
||||
Recording the adjustment makes the operational allowance visible
|
||||
without treating it as a user-specified blind-cut depth.
|
||||
"""
|
||||
if not require_through or contract.get("atomic_id") != "extrude_cut_blind":
|
||||
return []
|
||||
params = fragment.get("feature", {}).get("params", {})
|
||||
sketch = fragment.get("sketch") if isinstance(fragment.get("sketch"), dict) else {}
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else {}
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
thickness = self._span_from_bbox(self._active_body_bbox(selector_tokens), normal)
|
||||
distance = params.get("distance_mm") if isinstance(params, dict) else None
|
||||
if not isinstance(distance, (int, float)) or thickness is None:
|
||||
return []
|
||||
required_distance = float(thickness) + 0.01
|
||||
if float(distance) > float(thickness) + 1e-6:
|
||||
return []
|
||||
params["distance_mm"] = required_distance
|
||||
return [{
|
||||
"path": "/feature/params/distance_mm",
|
||||
"submitted_mm": float(distance),
|
||||
"materialized_mm": required_distance,
|
||||
"reason": "required through-cut exit allowance",
|
||||
}]
|
||||
|
||||
def _preflight_sketch_workplane(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
sketch = fragment.get("sketch")
|
||||
@@ -362,6 +498,123 @@ class ProfileCadRuntime:
|
||||
elif profile.get("type") == "analytic_contours":
|
||||
self._preflight_analytic_contours(profile)
|
||||
|
||||
def _preflight_extrude_cut_contacts_material(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]]) -> list[float] | None:
|
||||
"""Reject an extrude cut whose start profile floats above the solid.
|
||||
|
||||
Sketch cuts do not carry a host-face selector, so a model can place a
|
||||
correct 2-D profile on the top of an unrelated boss. The engine then
|
||||
rebuilds successfully but leaves the body unchanged. Detect the common
|
||||
no-contact form using the current planar topology and return a useful
|
||||
coordinate diagnosis before allocating a stage or running the kernel.
|
||||
"""
|
||||
sketch = fragment.get("sketch") if isinstance(fragment, dict) else None
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||
profile = sketch.get("profile") if isinstance(sketch, dict) else None
|
||||
origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
x_dir = workplane.get("x_dir") if isinstance(workplane, dict) else None
|
||||
if not self._valid_vector3(origin) or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(x_dir, require_nonzero=True) or not isinstance(profile, dict):
|
||||
return None
|
||||
unit_normal = [float(component) / self._norm(normal) for component in normal]
|
||||
x_projection = self._dot(x_dir, unit_normal)
|
||||
raw_x = [float(x_dir[index]) - x_projection * unit_normal[index] for index in range(3)]
|
||||
if self._norm(raw_x) <= 1e-9:
|
||||
return None
|
||||
unit_x = [value / self._norm(raw_x) for value in raw_x]
|
||||
unit_y = self._cross(unit_normal, unit_x)
|
||||
local_points = self._profile_probe_points(profile)
|
||||
if not local_points:
|
||||
return None
|
||||
world_points = [
|
||||
[
|
||||
float(origin[index]) + local[0] * unit_x[index] + local[1] * unit_y[index]
|
||||
for index in range(3)
|
||||
]
|
||||
for local in local_points
|
||||
]
|
||||
tolerance = 1e-5
|
||||
matching_faces: list[dict[str, Any]] = []
|
||||
available_heights: list[float] = []
|
||||
for value in selectors.values():
|
||||
geometry = value.get("geometry") if isinstance(value, dict) else None
|
||||
face_normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||
loops = geometry.get("boundary_loops_mm") if isinstance(geometry, dict) else None
|
||||
if (
|
||||
not isinstance(geometry, dict)
|
||||
or geometry.get("surface_type") != "plane"
|
||||
or not self._valid_vector3(face_normal, require_nonzero=True)
|
||||
or not self._valid_vector3(center)
|
||||
or not isinstance(loops, list)
|
||||
or not loops
|
||||
):
|
||||
continue
|
||||
unit_face_normal = [float(component) / self._norm(face_normal) for component in face_normal]
|
||||
if abs(abs(self._dot(unit_normal, unit_face_normal)) - 1.0) > 1e-6:
|
||||
continue
|
||||
available_heights.append(self._dot([float(center[index]) for index in range(3)], unit_normal))
|
||||
if abs(self._dot([float(origin[index]) - float(center[index]) for index in range(3)], unit_normal)) <= tolerance:
|
||||
matching_faces.append(geometry)
|
||||
if not matching_faces:
|
||||
return None
|
||||
for face in matching_faces:
|
||||
if any(
|
||||
self._point_in_planar_face(point, face["boundary_loops_mm"], unit_normal, tolerance)
|
||||
for point in world_points
|
||||
):
|
||||
face_normal = face.get("normal")
|
||||
return [float(component) for component in face_normal] if self._valid_vector3(face_normal, require_nonzero=True) else None
|
||||
plane_coordinate = self._dot([float(value) for value in origin], unit_normal)
|
||||
heights = ", ".join(f"{value:g}" for value in sorted(set(round(value, 6) for value in available_heights))[:8])
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: extrude-cut profile does not contact material on its start plane "
|
||||
f"(plane coordinate {plane_coordinate:g}; available parallel planar faces: [{heights}]); "
|
||||
"place the sketch on the material face containing the intended cut profile"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _profile_probe_points(profile: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
"""Return inexpensive local points sufficient for contact preflight."""
|
||||
points: list[tuple[float, float]] = []
|
||||
|
||||
def append(value: Any) -> None:
|
||||
point = ProfileCadRuntime._point2(value)
|
||||
if point is not None:
|
||||
points.append(point)
|
||||
|
||||
profile_type = profile.get("type")
|
||||
if profile_type == "circle":
|
||||
append(profile.get("center"))
|
||||
elif profile_type == "rectangle":
|
||||
append(profile.get("center"))
|
||||
elif profile_type == "polygon":
|
||||
vertices = profile.get("vertices")
|
||||
if isinstance(vertices, list):
|
||||
for vertex in vertices:
|
||||
append(vertex)
|
||||
elif profile_type == "analytic_contours":
|
||||
contours = profile.get("contours")
|
||||
if isinstance(contours, list):
|
||||
for contour in contours:
|
||||
segments = contour.get("segments") if isinstance(contour, dict) else None
|
||||
if not isinstance(segments, list):
|
||||
continue
|
||||
contour_points: list[tuple[float, float]] = []
|
||||
for segment in segments:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
for key in ("start", "end", "center"):
|
||||
point = ProfileCadRuntime._point2(segment.get(key))
|
||||
if point is not None:
|
||||
points.append(point)
|
||||
contour_points.append(point)
|
||||
if contour_points:
|
||||
points.append((
|
||||
sum(point[0] for point in contour_points) / len(contour_points),
|
||||
sum(point[1] for point in contour_points) / len(contour_points),
|
||||
))
|
||||
return points
|
||||
|
||||
def _preflight_host_face_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "face":
|
||||
@@ -439,7 +692,79 @@ class ProfileCadRuntime:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host-face bounds")
|
||||
boundary_loops = geometry.get("boundary_loops_mm")
|
||||
if isinstance(boundary_loops, list) and boundary_loops and not self._point_in_planar_face(point, boundary_loops, unit_normal, tolerance):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host face's effective boundary")
|
||||
if not self._counterbore_reuses_existing_pilot(fragment, selectors, point, unit_normal, tolerance):
|
||||
host_z = float(center[2]) if isinstance(center, list) and len(center) == 3 else float("nan")
|
||||
coordinate = ", ".join(f"{float(value):g}" for value in point)
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: hole position "
|
||||
f"[{coordinate}] lies outside the selected host face material boundary (host center z={host_z:g}); "
|
||||
"select a planar host face that contains every requested hole center"
|
||||
)
|
||||
|
||||
def _counterbore_reuses_existing_pilot(
|
||||
self,
|
||||
fragment: dict[str, Any],
|
||||
selectors: dict[str, dict[str, Any]],
|
||||
point: list[float],
|
||||
host_normal: list[float],
|
||||
tolerance: float,
|
||||
) -> bool:
|
||||
"""Allow a counterbore to start from an existing coaxial pilot bore.
|
||||
|
||||
A top face becomes annular after a through bore, so the pilot centre
|
||||
is deliberately outside its material boundary. Counterboring that
|
||||
pilot is nevertheless a standard valid operation. The exception is
|
||||
deliberately narrow: it only applies to a matching inner cylindrical
|
||||
bore whose axis is normal to the selected host plane and whose open
|
||||
end contains the requested start point.
|
||||
"""
|
||||
feature = fragment.get("feature") if isinstance(fragment, dict) else None
|
||||
params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {}
|
||||
pilot_diameter = params.get("diameter_mm")
|
||||
counterbore_diameter = params.get("counterbore_diameter_mm")
|
||||
if (
|
||||
not isinstance(feature, dict)
|
||||
or feature.get("atomic_id") != "hole_counterbore"
|
||||
or not isinstance(pilot_diameter, (int, float))
|
||||
or not isinstance(counterbore_diameter, (int, float))
|
||||
or float(pilot_diameter) <= 0
|
||||
or float(counterbore_diameter) <= float(pilot_diameter)
|
||||
):
|
||||
return False
|
||||
diameter_tolerance = max(tolerance, abs(float(pilot_diameter)) * 1e-6)
|
||||
for value in selectors.values():
|
||||
geometry = value.get("geometry") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(geometry, dict)
|
||||
or geometry.get("surface_type") != "cylinder"
|
||||
or geometry.get("cylinder_role") != "inner"
|
||||
or not bool(geometry.get("through"))
|
||||
):
|
||||
continue
|
||||
radius = geometry.get("radius_mm")
|
||||
axis_origin = geometry.get("axis_origin_mm")
|
||||
axis_direction = geometry.get("axis_direction")
|
||||
bbox = geometry.get("bbox_mm")
|
||||
if (
|
||||
not isinstance(radius, (int, float))
|
||||
or abs(2 * float(radius) - float(pilot_diameter)) > diameter_tolerance
|
||||
or not self._valid_vector3(axis_origin)
|
||||
or not self._valid_vector3(axis_direction, require_nonzero=True)
|
||||
or not self._valid_bbox(bbox)
|
||||
):
|
||||
continue
|
||||
unit_axis = [float(component) / self._norm(axis_direction) for component in axis_direction]
|
||||
if abs(abs(self._dot(unit_axis, host_normal)) - 1.0) > 1e-6:
|
||||
continue
|
||||
offset = [float(point[index]) - float(axis_origin[index]) for index in range(3)]
|
||||
axial = self._dot(offset, unit_axis)
|
||||
radial = [offset[index] - axial * unit_axis[index] for index in range(3)]
|
||||
if self._norm(radial) > tolerance:
|
||||
continue
|
||||
if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
def _preflight_analytic_contours(self, profile: dict[str, Any]) -> None:
|
||||
contours = profile.get("contours")
|
||||
@@ -750,9 +1075,9 @@ class ProfileCadRuntime:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path) -> dict[str, Any]:
|
||||
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path | None = None) -> dict[str, Any]:
|
||||
bbox = engine_result.get("bbox_mm") if isinstance(engine_result.get("bbox_mm"), dict) else {}
|
||||
minimum, maximum = bbox.get("min"), bbox.get("max")
|
||||
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or not glb_path.is_file():
|
||||
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or (glb_path is not None and not glb_path.is_file()):
|
||||
raise RuntimeAdapterError("rebuild did not produce complete deterministic artifacts")
|
||||
return {"bbox_mm": {"min": [float(item) for item in minimum], "max": [float(item) for item in maximum], "dimensions": [float(maximum[index]) - float(minimum[index]) for index in range(3)]}, "volume_mm3": float(engine_result["volume_mm3"]), "solid_count": int(engine_result["solid_count"]), "feature_count": len(engine_result.get("feature_results") or [])}
|
||||
|
||||
@@ -35,12 +35,12 @@ class SqliteTaskRepository:
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
# Protocol 3.1 intentionally has no migration path from the
|
||||
# Protocol 3.2 intentionally has no migration path from the
|
||||
# structured-only / review-loop task model. Deployment starts with
|
||||
# an empty task database, as those tasks do not have immutable
|
||||
# Markdown source artifacts to compile from.
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
|
||||
if existing is not None and "'3.1'" not in str(existing[0] or ""):
|
||||
if existing is not None and "'3.2'" not in str(existing[0] or ""):
|
||||
self.protocol_reset = True
|
||||
connection.executescript("""
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
@@ -55,7 +55,7 @@ class SqliteTaskRepository:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.1'),
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
|
||||
request TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
@@ -70,6 +70,9 @@ class SqliteTaskRepository:
|
||||
requirements_document_path TEXT NOT NULL DEFAULT '',
|
||||
completion_target_path TEXT NOT NULL DEFAULT '',
|
||||
modeling_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_hash TEXT NOT NULL DEFAULT '',
|
||||
feature_stage_id TEXT NOT NULL DEFAULT '',
|
||||
clarification_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -124,7 +127,7 @@ class SqliteTaskRepository:
|
||||
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.1', ?, ?, 0)",
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
|
||||
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
|
||||
)
|
||||
state = self.get_state(task_id)
|
||||
@@ -145,10 +148,10 @@ class SqliteTaskRepository:
|
||||
revisions = [
|
||||
{
|
||||
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
}
|
||||
for item in events
|
||||
if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str)
|
||||
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
|
||||
]
|
||||
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
|
||||
verification_warnings = [
|
||||
@@ -167,7 +170,7 @@ class SqliteTaskRepository:
|
||||
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
return {
|
||||
"schema_version": "3.1",
|
||||
"schema_version": "3.2",
|
||||
"task_id": state.task_id,
|
||||
"phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase),
|
||||
@@ -184,6 +187,11 @@ class SqliteTaskRepository:
|
||||
"requirements_document_path": state.requirements_document_path,
|
||||
"completion_target_path": state.completion_target_path,
|
||||
"modeling_plan_path": state.modeling_plan_path,
|
||||
"feature_plan_path": state.feature_plan_path,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
|
||||
"pending_feature": self._pending_payload(state.pending_feature),
|
||||
"feature_nodes": self._feature_node_projection(events),
|
||||
"clarification_path": state.clarification_path,
|
||||
"requirements_contract_path": state.requirements_contract_path,
|
||||
"verification_status": (
|
||||
@@ -204,6 +212,25 @@ class SqliteTaskRepository:
|
||||
"revisions": revisions,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if not node_id:
|
||||
continue
|
||||
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
|
||||
if event.get("event") == "feature_node_scheduled":
|
||||
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
|
||||
elif event.get("event") == "feature_node_failed":
|
||||
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
|
||||
elif event.get("event") == "feature_node_verified":
|
||||
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
|
||||
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
|
||||
item["status"] = "invalidated"
|
||||
return list(nodes.values())
|
||||
|
||||
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
|
||||
@@ -243,7 +270,7 @@ class SqliteTaskRepository:
|
||||
cursor = connection.execute(
|
||||
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND state_version = ?""",
|
||||
(
|
||||
state.phase.value, state.version, state.active_revision,
|
||||
@@ -252,6 +279,7 @@ class SqliteTaskRepository:
|
||||
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
|
||||
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
|
||||
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
|
||||
state.clarification_path, state.requirements_contract_path,
|
||||
state.task_id, previous_version,
|
||||
),
|
||||
@@ -393,6 +421,8 @@ class SqliteTaskRepository:
|
||||
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
|
||||
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
|
||||
) if isinstance(raw_pending, dict) else None
|
||||
return TaskState(
|
||||
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||
@@ -405,6 +435,9 @@ class SqliteTaskRepository:
|
||||
requirements_document_path=str(row["requirements_document_path"] or ""),
|
||||
completion_target_path=str(row["completion_target_path"] or ""),
|
||||
modeling_plan_path=str(row["modeling_plan_path"] or ""),
|
||||
feature_plan_path=str(row["feature_plan_path"] or ""),
|
||||
feature_plan_hash=str(row["feature_plan_hash"] or ""),
|
||||
feature_stage_id=str(row["feature_stage_id"] or ""),
|
||||
clarification_path=str(row["clarification_path"] or ""),
|
||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||
)
|
||||
@@ -413,7 +446,7 @@ class SqliteTaskRepository:
|
||||
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||
if pending is None:
|
||||
return None
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key}
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
|
||||
|
||||
@staticmethod
|
||||
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -35,11 +36,13 @@ def compose_v3(settings: Settings) -> V3Services:
|
||||
# Protocol 3.1 has no valid interpretation for structured-only task
|
||||
# artifacts, so clear that task root together with its old database.
|
||||
shutil.rmtree(settings.task_root)
|
||||
if repository.protocol_reset:
|
||||
WorkspaceStore(settings).clear_current_task_references()
|
||||
artifacts = FileArtifactStore(settings.task_root)
|
||||
runtime = ProfileCadRuntime(settings)
|
||||
registry = default_registry()
|
||||
verifier = RegistryVerifierExecutor(registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
|
||||
fallbacks = tuple(
|
||||
ModelIdentity(provider.id, model.id)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class FeatureNode(_StrictModel):
|
||||
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
priority: int = Field(ge=0, le=100_000)
|
||||
intent: str = Field(min_length=1, max_length=360)
|
||||
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=64)
|
||||
claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
expected_change: str = Field(min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_references(self) -> "FeatureNode":
|
||||
if len(self.depends_on) != len(set(self.depends_on)):
|
||||
raise ValueError("depends_on must not contain duplicates")
|
||||
if len(self.claim_ids) != len(set(self.claim_ids)):
|
||||
raise ValueError("claim_ids must not contain duplicates")
|
||||
if self.node_id in self.depends_on:
|
||||
raise ValueError("a feature node cannot depend on itself")
|
||||
return self
|
||||
|
||||
|
||||
class FeaturePlan(_StrictModel):
|
||||
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
|
||||
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
|
||||
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
|
||||
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_plan_fields(self) -> "FeaturePlan":
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
priorities = [node.priority for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id values must be unique")
|
||||
if len(priorities) != len(set(priorities)):
|
||||
raise ValueError("priority values must be unique")
|
||||
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
|
||||
raise ValueError("replaces_node_ids must not contain duplicates")
|
||||
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
|
||||
raise ValueError("final_claim_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
|
||||
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
|
||||
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
|
||||
result[claim["claim_id"]] = claim
|
||||
return result
|
||||
|
||||
|
||||
def validate_feature_plan(
|
||||
plan: FeaturePlan,
|
||||
contract: dict[str, Any],
|
||||
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
|
||||
*,
|
||||
previous_plan: FeaturePlan | None = None,
|
||||
completed_node_hashes: dict[str, str] | None = None,
|
||||
required_replacements: set[str] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return stable, tool-facing validation errors for a frozen plan."""
|
||||
errors: list[dict[str, str]] = []
|
||||
nodes = {node.node_id: node for node in plan.nodes}
|
||||
known_atoms = set(atomic_ids)
|
||||
claims = contract_claims(contract)
|
||||
assigned: dict[str, str] = {}
|
||||
|
||||
for index, node in enumerate(plan.nodes):
|
||||
prefix = f"/nodes/{index}"
|
||||
if node.atomic_id not in known_atoms:
|
||||
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
|
||||
for dependency in node.depends_on:
|
||||
if dependency not in nodes:
|
||||
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
|
||||
for claim_id in node.claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
continue
|
||||
if claim.get("verification_mode") != "deterministic":
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
|
||||
owner = assigned.setdefault(claim_id, node.node_id)
|
||||
if owner != node.node_id:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
|
||||
|
||||
final = set(plan.final_claim_ids)
|
||||
for claim_id in plan.final_claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
elif claim.get("verification_mode") == "deterministic":
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
|
||||
|
||||
for claim_id, claim in claims.items():
|
||||
deterministic = claim.get("verification_mode") == "deterministic"
|
||||
if deterministic and claim_id not in assigned:
|
||||
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
|
||||
if not deterministic and claim_id not in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
|
||||
if deterministic and claim_id in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
|
||||
|
||||
errors.extend(_cycle_errors(nodes))
|
||||
if previous_plan is None:
|
||||
if plan.parent_plan_hash:
|
||||
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
|
||||
if plan.replaces_node_ids:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
|
||||
else:
|
||||
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
|
||||
return errors
|
||||
|
||||
|
||||
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def walk(current: str) -> None:
|
||||
if current in visiting:
|
||||
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
|
||||
return
|
||||
if current in visited:
|
||||
return
|
||||
visiting.add(current)
|
||||
for dependency in nodes[current].depends_on:
|
||||
if dependency in nodes:
|
||||
walk(dependency)
|
||||
visiting.remove(current)
|
||||
visited.add(current)
|
||||
|
||||
for node_id in nodes:
|
||||
walk(node_id)
|
||||
return errors[:1]
|
||||
|
||||
|
||||
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
next_nodes = {node.node_id: node for node in plan.nodes}
|
||||
if plan.parent_plan_hash != plan_hash(previous):
|
||||
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
|
||||
replaced = set(plan.replaces_node_ids)
|
||||
if required_replacements and replaced != required_replacements:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
|
||||
for node_id, frozen_hash in completed.items():
|
||||
node = next_nodes.get(node_id)
|
||||
if node is None:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
|
||||
elif node_hash(node) != frozen_hash:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
|
||||
if node_id in replaced:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
|
||||
for node_id, old_node in old_nodes.items():
|
||||
if node_id in replaced:
|
||||
continue
|
||||
current = next_nodes.get(node_id)
|
||||
if current is None:
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
|
||||
elif node_hash(current) != node_hash(old_node):
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
|
||||
for node_id in replaced:
|
||||
if node_id not in old_nodes:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
|
||||
if node_id in next_nodes:
|
||||
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
|
||||
return errors
|
||||
|
||||
|
||||
class FeatureScheduler:
|
||||
"""Derive a plan's runnable node from immutable ledger evidence."""
|
||||
|
||||
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
|
||||
self.plan = plan
|
||||
self.events = events
|
||||
self._nodes = {node.node_id: node for node in plan.nodes}
|
||||
|
||||
def statuses(self) -> dict[str, str]:
|
||||
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
|
||||
continue
|
||||
if event.get("event") == "feature_node_verified":
|
||||
states[node_id] = "done"
|
||||
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
|
||||
states[node_id] = "invalidated"
|
||||
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
|
||||
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
|
||||
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
|
||||
states[node_id] = "running"
|
||||
for node in self.plan.nodes:
|
||||
if states[node.node_id] in {"done", "failed", "invalidated"}:
|
||||
continue
|
||||
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
|
||||
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
|
||||
states[node.node_id] = "blocked"
|
||||
elif all(value == "done" for value in dependency_states):
|
||||
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
|
||||
return states
|
||||
|
||||
def next_ready(self) -> FeatureNode | None:
|
||||
statuses = self.statuses()
|
||||
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
|
||||
return min(ready, key=lambda node: node.priority) if ready else None
|
||||
|
||||
def all_done(self) -> bool:
|
||||
return all(value == "done" for value in self.statuses().values())
|
||||
|
||||
def feature_ids(self) -> dict[str, str]:
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
result: dict[str, str] = {}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
feature_id = str(event.get("feature_id") or "")
|
||||
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
|
||||
result[node_id] = feature_id
|
||||
return result
|
||||
|
||||
def completed_node_hashes(self) -> dict[str, str]:
|
||||
statuses = self.statuses()
|
||||
return {
|
||||
node_id: node_hash(self._nodes[node_id])
|
||||
for node_id, status in statuses.items()
|
||||
if status == "done"
|
||||
}
|
||||
|
||||
def failure_count(self, node_id: str, failure_class: str) -> int:
|
||||
expected = node_hash(self._nodes[node_id])
|
||||
return sum(
|
||||
1
|
||||
for event in self.events
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and event.get("node_id") == node_id
|
||||
and event.get("node_hash") == expected
|
||||
and event.get("failure_class") == failure_class
|
||||
)
|
||||
@@ -12,6 +12,13 @@ class TaskPhase(StrEnum):
|
||||
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
|
||||
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
|
||||
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
|
||||
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
|
||||
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
|
||||
FEATURE_PENDING = "FEATURE_PENDING"
|
||||
FEATURE_BUILDING = "FEATURE_BUILDING"
|
||||
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
|
||||
# Legacy v3.1 phases remain readable only so an interrupted process can
|
||||
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
|
||||
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
|
||||
AWAITING_ACTION = "AWAITING_ACTION"
|
||||
ACTION_PENDING = "ACTION_PENDING"
|
||||
@@ -35,6 +42,15 @@ class PendingAction:
|
||||
expected_change: str
|
||||
contract_hash: str
|
||||
idempotency_key: str
|
||||
node_id: str = ""
|
||||
plan_hash: str = ""
|
||||
claim_ids: tuple[str, ...] = ()
|
||||
depends_on_node_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
|
||||
# v3.2 code uses this alias to make the ownership boundary explicit.
|
||||
PendingFeature = PendingAction
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -53,9 +69,16 @@ class TaskState:
|
||||
requirements_document_path: str = ""
|
||||
completion_target_path: str = ""
|
||||
modeling_plan_path: str = ""
|
||||
feature_plan_path: str = ""
|
||||
feature_plan_hash: str = ""
|
||||
feature_stage_id: str = ""
|
||||
clarification_path: str = ""
|
||||
requirements_contract_path: str = ""
|
||||
|
||||
@property
|
||||
def pending_feature(self) -> PendingFeature | None:
|
||||
return self.pending_action
|
||||
|
||||
@property
|
||||
def working_head(self) -> str:
|
||||
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
|
||||
@@ -67,7 +90,21 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
|
||||
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.DRAFTING_MODELING_PLAN,
|
||||
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
|
||||
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
|
||||
# exposes this event or accepts a Markdown plan from a model.
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
# User clarifications are durable task evidence. Resume on the same task
|
||||
@@ -89,6 +126,7 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
|
||||
}
|
||||
@@ -111,6 +149,11 @@ _RETRY_RESUMABLE_PHASES = frozenset({
|
||||
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
TaskPhase.COMPILING_REQUIREMENTS,
|
||||
TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
TaskPhase.SCHEDULING_FEATURE,
|
||||
TaskPhase.FEATURE_PENDING,
|
||||
TaskPhase.FEATURE_BUILDING,
|
||||
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
TaskPhase.DRAFTING_MODELING_PLAN,
|
||||
TaskPhase.AWAITING_ACTION,
|
||||
TaskPhase.ACTION_PENDING,
|
||||
@@ -139,7 +182,7 @@ def retry_resume_event(state: TaskState) -> str | None:
|
||||
return f"resume_{state.retry_from_phase.value.lower()}"
|
||||
|
||||
|
||||
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, feature_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||
"""Apply one legal transition and advance optimistic-concurrency version."""
|
||||
target = _TRANSITIONS.get((state.phase, event))
|
||||
if target is None:
|
||||
@@ -147,7 +190,7 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
||||
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
|
||||
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
|
||||
next_pending = state.pending_action if pending_action is ... else pending_action
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
next_pending = None
|
||||
return replace(
|
||||
state,
|
||||
@@ -155,8 +198,8 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
||||
version=state.version + 1,
|
||||
active_revision=state.active_revision if active_revision is None else active_revision,
|
||||
pending_action=next_pending,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||
last_error=error,
|
||||
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||
@@ -164,6 +207,9 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
||||
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
|
||||
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
|
||||
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
|
||||
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
|
||||
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
|
||||
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
|
||||
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
|
||||
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
||||
)
|
||||
|
||||
@@ -647,6 +647,60 @@ def _coaxial_through_bore_group(expected: dict[str, Any], facts: dict[str, Any])
|
||||
return _pass({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance}) if all(item["axis_dot"] >= 1 - 1e-6 and item["axis_distance_mm"] <= tolerance for item in deviations) else _fail({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance})
|
||||
|
||||
|
||||
def _concentric_bore_to_outer_cylinder(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||
"""Verify one bore axis coincides with one external cylindrical shell.
|
||||
|
||||
This is distinct from ``coaxial_through_bore_group``, which compares two
|
||||
or more inner bores. A centered flange bore has only one inner cylinder,
|
||||
so treating its outer wall as a second bore makes the old verifier
|
||||
permanently pending.
|
||||
"""
|
||||
if not _records(facts):
|
||||
return _pending("topology is unavailable")
|
||||
tolerance = float(expected["tolerance_mm"])
|
||||
bore = _matching_cylinders({
|
||||
"diameter_mm": expected["bore_diameter_mm"],
|
||||
"tolerance_mm": tolerance,
|
||||
}, facts)
|
||||
outer_shells = _outer_cylinder_shells({
|
||||
"diameter_mm": expected["outer_diameter_mm"],
|
||||
"tolerance_mm": tolerance,
|
||||
}, facts)
|
||||
if not bore:
|
||||
return _pending("the target bore has not been introduced at this checkpoint")
|
||||
if not outer_shells:
|
||||
return _pending("the target outer cylindrical surface has not been introduced at this checkpoint")
|
||||
if len(bore) != 1 or len(outer_shells) != 1:
|
||||
return _fail({
|
||||
"expected_bore_count": 1,
|
||||
"actual_bore_count": len(bore),
|
||||
"expected_outer_count": 1,
|
||||
"actual_outer_count": len(outer_shells),
|
||||
"bore_diameter_mm": expected["bore_diameter_mm"],
|
||||
"outer_diameter_mm": expected["outer_diameter_mm"],
|
||||
})
|
||||
bore_point = _cylinder_axis_point(bore[0])
|
||||
bore_axis = _cylinder_axis_direction(bore[0])
|
||||
outer_point = _cylinder_axis_point(outer_shells[0][0])
|
||||
outer_axis = _cylinder_axis_direction(outer_shells[0][0])
|
||||
if None in {bore_point, bore_axis, outer_point, outer_axis}:
|
||||
return _pending("the bore or outer-cylinder axis is not measurable")
|
||||
assert bore_point is not None and bore_axis is not None
|
||||
assert outer_point is not None and outer_axis is not None
|
||||
axis_dot = abs(sum(bore_axis[index] * outer_axis[index] for index in range(3)))
|
||||
delta = tuple(bore_point[index] - outer_point[index] for index in range(3))
|
||||
axial = sum(delta[index] * outer_axis[index] for index in range(3))
|
||||
radial_distance = sqrt(sum((delta[index] - axial * outer_axis[index]) ** 2 for index in range(3)))
|
||||
evidence = {
|
||||
"bore_record_id": bore[0].get("record_id"),
|
||||
"outer_record_ids": [record.get("record_id") for record in outer_shells[0]],
|
||||
"axis_dot": axis_dot,
|
||||
"axis_distance_mm": radial_distance,
|
||||
"tolerance_mm": tolerance,
|
||||
}
|
||||
return _pass(evidence) if axis_dot >= 1 - 1e-6 and radial_distance <= tolerance else _fail(evidence)
|
||||
|
||||
|
||||
def _orthogonal_intersecting_through_bores(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||
first = _matching_cylinders({"diameter_mm": expected["first_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
|
||||
second = _matching_cylinders({"diameter_mm": expected["second_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
|
||||
@@ -889,6 +943,7 @@ def default_registry() -> VerifierRegistry:
|
||||
ClaimDefinition("circular_hole_pattern", _closed_object({"count": {"type": "integer", "minimum": 2}, "diameter_mm": positive, "pitch_radius_mm": positive, "concentric_bore_diameter_mm": positive, "tolerance_mm": tolerance}, ["count", "diameter_mm", "pitch_radius_mm", "tolerance_mm"]), ("topology",), _hole_pattern),
|
||||
ClaimDefinition("collinear_through_bore_chain", bore_chain, ("topology",), _collinear_bore_chain),
|
||||
ClaimDefinition("coaxial_through_bore_group", _closed_object({"diameter_mm": positive, "count": {"type": "integer", "minimum": 2, "maximum": 16}, "tolerance_mm": alignment_tolerance}, ["diameter_mm", "count", "tolerance_mm"]), ("topology",), _coaxial_through_bore_group),
|
||||
ClaimDefinition("concentric_bore_to_outer_cylinder", _closed_object({"bore_diameter_mm": positive, "outer_diameter_mm": positive, "tolerance_mm": alignment_tolerance}, ["bore_diameter_mm", "outer_diameter_mm", "tolerance_mm"]), ("topology",), _concentric_bore_to_outer_cylinder),
|
||||
ClaimDefinition("orthogonal_intersecting_through_bores", _closed_object({"first_diameter_mm": positive, "second_diameter_mm": positive, "first_axis": {"enum": ["x", "y", "z"]}, "second_axis": {"enum": ["x", "y", "z"]}, "tolerance_mm": alignment_tolerance}, ["first_diameter_mm", "second_diameter_mm", "first_axis", "second_axis", "tolerance_mm"]), ("topology",), _orthogonal_intersecting_through_bores),
|
||||
ClaimDefinition("rectangular_corner_through_bore_pattern", _closed_object({"diameter_mm": positive, "count": {"const": 4}, "edge_offset_mm": positive, "tolerance_mm": tolerance}, ["diameter_mm", "count", "edge_offset_mm", "tolerance_mm"]), ("rebuild_report", "topology"), _rectangular_corner_bore_pattern),
|
||||
ClaimDefinition("coaxial", _closed_object({"record_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "maxItems": 2, "uniqueItems": True}, "tolerance": alignment_tolerance}, ["record_ids", "tolerance"]), ("topology",), _coaxial),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Create a resumable live-evaluation task without starting its workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--task-id")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
root = arguments.report_root.resolve()
|
||||
settings = get_settings()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=root / "artifacts",
|
||||
conversation_root=root / "conversations",
|
||||
))
|
||||
task_id = arguments.task_id or f"cad_{secrets.token_hex(6)}"
|
||||
services.workflow.create_task(task_id, arguments.prompt)
|
||||
print(json.dumps({"task_id": task_id, "report_root": str(root)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -36,7 +36,7 @@ from app.cad_agent.evals.token_baseline import (
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"candidate.json",
|
||||
"candidate-review.json",
|
||||
"model.cdsl.json",
|
||||
@@ -48,6 +48,19 @@ _CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"renders/contact-sheet.jpg",
|
||||
})
|
||||
|
||||
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
|
||||
# It deliberately has no candidate review or technical render bundle; the
|
||||
# final review owns that later evidence. GLB is optional because a preview
|
||||
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
|
||||
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
|
||||
"input.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.topology.json",
|
||||
"node-verification.json",
|
||||
"rebuild-report.json",
|
||||
})
|
||||
|
||||
_FAILURE_LAYERS = frozenset({
|
||||
"model_format_or_decision",
|
||||
"v3_contract_or_verifier",
|
||||
@@ -240,13 +253,14 @@ def _artifact_evidence_complete(
|
||||
revisions: list[str],
|
||||
active_revision: str,
|
||||
artifact_manifest: dict[str, Any] | None,
|
||||
ledger: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Require every published CAD decision to retain its reviewable evidence.
|
||||
|
||||
A non-empty report manifest is not enough: a task could otherwise report
|
||||
only a rendered contract view while silently losing the STEP, topology, or
|
||||
candidate review used to accept a revision. Candidate manifests protect
|
||||
the immutable build inputs/outputs; the report manifest additionally
|
||||
node verification used to accept a revision. Checkpoint manifests protect
|
||||
immutable build inputs and outputs; the report manifest additionally
|
||||
protects the frozen contract and final independent review written later.
|
||||
"""
|
||||
if not revisions or not active_revision:
|
||||
@@ -264,9 +278,19 @@ def _artifact_evidence_complete(
|
||||
return False
|
||||
if not all((artifact_root / path).is_file() for path in required_report_files):
|
||||
return False
|
||||
feature_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
|
||||
}
|
||||
for revision_id in sorted(set(revisions)):
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
required_paths = {revision_root / relative for relative in _CANDIDATE_EVIDENCE_FILES}
|
||||
evidence_files = (
|
||||
_FEATURE_NODE_EVIDENCE_FILES
|
||||
if revision_id in feature_revisions
|
||||
else _LEGACY_CANDIDATE_EVIDENCE_FILES
|
||||
)
|
||||
required_paths = {revision_root / relative for relative in evidence_files}
|
||||
manifest_path = revision_root / "manifest.json"
|
||||
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
|
||||
return False
|
||||
@@ -275,7 +299,7 @@ def _artifact_evidence_complete(
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
declared = manifest.get("files") if isinstance(manifest, dict) else None
|
||||
if not isinstance(declared, dict) or not _CANDIDATE_EVIDENCE_FILES.issubset(declared):
|
||||
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
|
||||
return False
|
||||
for relative, digest in declared.items():
|
||||
path = revision_root / str(relative)
|
||||
@@ -284,7 +308,7 @@ def _artifact_evidence_complete(
|
||||
if sha256(path.read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
if artifact_manifest is not None:
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in _CANDIDATE_EVIDENCE_FILES}
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
|
||||
if not expected_report_paths.issubset(report_files):
|
||||
return False
|
||||
return True
|
||||
@@ -356,9 +380,24 @@ def _run_checks(
|
||||
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
|
||||
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
|
||||
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
|
||||
accepted = [item for item in audit_ledger if isinstance(item, dict) and item.get("event") == "accepted"]
|
||||
revisions = [str(item.get("revision_id") or "") for item in accepted]
|
||||
operation_ids = {str(item.get("actual_atomic_id") or "") for item in accepted}
|
||||
published = [
|
||||
item for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
|
||||
]
|
||||
revisions = [str(item.get("revision_id") or "") for item in published]
|
||||
scheduled_atomic_ids = {
|
||||
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
|
||||
for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
|
||||
}
|
||||
operation_ids = {
|
||||
str(
|
||||
item.get("actual_atomic_id")
|
||||
or item.get("atomic_id")
|
||||
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
|
||||
)
|
||||
for item in published
|
||||
}
|
||||
required_operation_groups = [
|
||||
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
|
||||
for group in scenario.get("required_any_atomic_id_groups") or ()
|
||||
@@ -386,6 +425,7 @@ def _run_checks(
|
||||
revisions,
|
||||
active_revision,
|
||||
artifact_manifest,
|
||||
audit_ledger,
|
||||
)
|
||||
return {
|
||||
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
|
||||
@@ -712,7 +752,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"reviewer": str(reviewer_capability.get("mode") or ""),
|
||||
},
|
||||
"git_revision": _git_revision(),
|
||||
"protocol_version": "3.0",
|
||||
"protocol_version": "3.2",
|
||||
"runtime_profile_sha256": runtime_profile_hash,
|
||||
"operation_contracts": contracts,
|
||||
"verifier_registry_version": "cad.verifier-registry.v1",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Advance one persisted event for a task in an isolated live-evaluation root.
|
||||
|
||||
Long real-provider evaluations can outlive a command host's execution window.
|
||||
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
|
||||
closes the async generator after that event has been durably handled. Repeated
|
||||
invocations therefore resume the same task without re-running already
|
||||
persisted nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
report_root = arguments.report_root.resolve()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=report_root / "artifacts",
|
||||
conversation_root=report_root / "conversations",
|
||||
))
|
||||
before = services.repository.get_state(arguments.task_id)
|
||||
if before is None:
|
||||
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
|
||||
runner = services.workflow.run(
|
||||
task_id=arguments.task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
)
|
||||
try:
|
||||
name, payload = await anext(runner)
|
||||
except StopAsyncIteration:
|
||||
name, payload = "workflow_exhausted", {}
|
||||
finally:
|
||||
await runner.aclose()
|
||||
after = services.repository.get_state(arguments.task_id)
|
||||
return {
|
||||
"task_id": arguments.task_id,
|
||||
"event": {"name": name, "payload": payload},
|
||||
"before": {"phase": before.phase.value, "version": before.version},
|
||||
"after": {
|
||||
"phase": after.phase.value if after is not None else "",
|
||||
"version": after.version if after is not None else -1,
|
||||
"active_revision": after.active_revision if after is not None else "",
|
||||
"last_error": after.last_error.value if after is not None and after.last_error else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
try:
|
||||
result = asyncio.run(_advance(arguments))
|
||||
except BaseException as error:
|
||||
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -86,7 +86,10 @@ class CadRuntime(Protocol):
|
||||
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
|
||||
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
|
||||
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def render_review_bundle(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
|
||||
|
||||
|
||||
+39
-6
@@ -7,6 +7,7 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.models.contracts import ChatRequest, ConversationPatch
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
|
||||
@@ -158,12 +159,27 @@ async def read_task(task_id: str) -> JSONResponse:
|
||||
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
|
||||
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
|
||||
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
|
||||
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.modeling_plan_path) if state is not None and state.modeling_plan_path else None
|
||||
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
|
||||
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
|
||||
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
|
||||
task["modeling_plan_markdown"] = plan_path.read_text(encoding="utf-8") if plan_path and plan_path.is_file() else None
|
||||
task["modeling_plan_path"] = state.modeling_plan_path if plan_path and plan_path.is_file() else ""
|
||||
task["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
|
||||
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
|
||||
if isinstance(task["feature_plan"], dict):
|
||||
plan = FeaturePlan.model_validate(task["feature_plan"])
|
||||
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
|
||||
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
|
||||
task["feature_nodes"] = [
|
||||
{
|
||||
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
|
||||
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
|
||||
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
|
||||
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
|
||||
**node_evidence.get(str(node.get("node_id") or ""), {}),
|
||||
}
|
||||
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
|
||||
]
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
|
||||
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
|
||||
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
||||
@@ -179,7 +195,7 @@ def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[st
|
||||
"""
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for event in reversed(ledger if isinstance(ledger, list) else []):
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed"}:
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
|
||||
continue
|
||||
values = event.get("claim_results")
|
||||
if not isinstance(values, list):
|
||||
@@ -291,8 +307,25 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
# A running task may render its current checkpoint in the browser, but
|
||||
# cannot expose any other checkpoint artifact or a failed-task preview.
|
||||
v32_checkpoint_paths = {
|
||||
f"revisions/{revision_id}/model.cdsl.json",
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
}
|
||||
verified_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
}
|
||||
# Each v3.2 node revision is immutable and manifest-published. Make those
|
||||
# checkpoints inspectable from the DAG while keeping every staging input,
|
||||
# rejected candidate and arbitrary task artifact private.
|
||||
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
|
||||
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
|
||||
|
||||
# A legacy running task may render its active checkpoint in the browser,
|
||||
# but cannot expose any other checkpoint artifact or failed-task preview.
|
||||
if (
|
||||
str(task.get("lifecycle") or "") != "running"
|
||||
or revision_id != active_revision
|
||||
|
||||
@@ -96,6 +96,19 @@ class WorkspaceStore:
|
||||
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
|
||||
return _read_json(self.conversation_path(conversation_id))
|
||||
|
||||
def clear_current_task_references(self) -> int:
|
||||
"""Detach conversations from task artifacts removed by a protocol reset."""
|
||||
cleared = 0
|
||||
for path in self.settings.conversation_root.glob("conv_*/conversation.json"):
|
||||
record = _read_json(path)
|
||||
if not isinstance(record, dict) or not record.get("current_task_id"):
|
||||
continue
|
||||
record["current_task_id"] = ""
|
||||
record["updated_at"] = now_iso()
|
||||
_write_json(path, record)
|
||||
cleared += 1
|
||||
return cleared
|
||||
|
||||
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
|
||||
record = self.ensure_conversation(conversation_id, current_task_id)
|
||||
known = {str(item.get("id") or "") for item in record["messages"] if isinstance(item, dict)}
|
||||
|
||||
@@ -18,12 +18,13 @@ sys.path.insert(0, str(ROOT / "backend"))
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime, RuntimeAdapterError
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
AcceptanceClaimInput,
|
||||
CandidateReview,
|
||||
CompiledRequirementsSpec,
|
||||
MarkdownDocument,
|
||||
@@ -42,9 +43,9 @@ from app.cad_agent.application.outbox import OutboxDispatcher
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
|
||||
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.models.contracts import ChatMessage
|
||||
from app.services.agent_service import AgentService
|
||||
@@ -107,7 +108,10 @@ def completion_target() -> MarkdownDocument:
|
||||
def compiled_flange() -> CompiledRequirementsSpec:
|
||||
return CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
|
||||
@@ -196,6 +200,194 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"]
|
||||
self.assertEqual(selector, {"enum": ["selector_one"]})
|
||||
|
||||
def test_counterbore_can_reuse_a_matching_pilot_inside_an_annular_host_face(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
host = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
|
||||
"bbox_mm": [-48, -48, 34, 48, 48, 34],
|
||||
"boundary_loops_mm": [
|
||||
[[-48, -48, 34], [48, -48, 34], [48, 48, 34], [-48, 48, 34]],
|
||||
[[-16, -16, 34], [16, -16, 34], [16, 16, 34], [-16, 16, 34]],
|
||||
],
|
||||
},
|
||||
}
|
||||
pilot = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "cylinder", "cylinder_role": "inner", "through": True,
|
||||
"radius_mm": 16, "axis_origin_mm": [0, 0, 0], "axis_direction": [0, 0, 1],
|
||||
"bbox_mm": [-16, -16, 0, 16, 16, 34],
|
||||
},
|
||||
}
|
||||
fragment = {
|
||||
"feature": {
|
||||
"atomic_id": "hole_counterbore", "selector_tokens": ["host"],
|
||||
"params": {"diameter_mm": 32, "depth_mm": 34, "counterbore_diameter_mm": 62, "counterbore_depth_mm": 12, "positions": [{"mm": [0, 0, 34]}]},
|
||||
},
|
||||
}
|
||||
runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False)
|
||||
fragment["feature"]["params"]["diameter_mm"] = 30
|
||||
with self.assertRaises(RuntimeAdapterError):
|
||||
runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False)
|
||||
|
||||
def test_counterbore_operation_verifier_measures_the_new_recess_not_the_existing_pilot(self) -> None:
|
||||
class CounterboreVerifier:
|
||||
def __init__(self) -> None:
|
||||
self.operation_claims: list[dict[str, object]] = []
|
||||
|
||||
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
|
||||
if claims[0]["claim_id"] == "operation_parent_bore_count":
|
||||
self.assertEqual(claims[0]["expected"]["diameter_mm"], 62.0)
|
||||
return [{"evidence": {"actual_count": 0}}]
|
||||
self.operation_claims = claims
|
||||
return [{"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": True, "status": "pass", "evidence": {}} for claim in claims]
|
||||
|
||||
def assertEqual(self, actual: object, expected: object) -> None:
|
||||
if actual != expected:
|
||||
raise AssertionError(f"{actual!r} != {expected!r}")
|
||||
|
||||
verifier = CounterboreVerifier()
|
||||
handler = ActionCommandHandler(None, None, None, verifier)
|
||||
action = PendingAction(
|
||||
action_id="counterbore", working_head="cad_test:rev_001:v1", intent="Counterbore.", requirement_ids=(),
|
||||
atomic_id="hole_counterbore", expected_change="Cut a counterbore.", contract_hash="contract", idempotency_key="key",
|
||||
)
|
||||
results = handler._operation_candidate_results(
|
||||
action,
|
||||
{"candidate_verifiers": ["cylindrical_bore"]},
|
||||
{"features": [{"atomic_id": "hole_counterbore", "params": {"diameter_mm": 32, "counterbore_diameter_mm": 62, "positions": [{"mm": [0, 0, 34]}]}}]},
|
||||
{"health": {}, "topology": {}, "report": {}},
|
||||
parent_facts={"health": {}, "topology": {}, "report": {}},
|
||||
require_through=False,
|
||||
)
|
||||
self.assertEqual(verifier.operation_claims[0]["expected"], {"diameter_mm": 62.0, "count": 1, "tolerance_mm": 0.01})
|
||||
self.assertEqual(results[0]["status"], "pass")
|
||||
|
||||
def test_extrude_cut_rejects_a_slot_profile_floating_above_the_base(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
base_face = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1],
|
||||
"boundary_loops_mm": [[[-90, -50, 16], [90, -50, 16], [90, 50, 16], [-90, 50, 16]]],
|
||||
},
|
||||
}
|
||||
boss_face = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
|
||||
"boundary_loops_mm": [[[48, 0, 34], [0, 48, 34], [-48, 0, 34], [0, -48, 34]]],
|
||||
},
|
||||
}
|
||||
fragment = {
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 34], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "analytic_contours", "contours": [{"closed": True, "role": "outer", "segments": [
|
||||
{"type": "line", "start": [68, 40], "end": [85, 40]},
|
||||
{"type": "line", "start": [85, 40], "end": [85, 49]},
|
||||
{"type": "line", "start": [85, 49], "end": [68, 49]},
|
||||
{"type": "line", "start": [68, 49], "end": [68, 40]},
|
||||
]}]},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}},
|
||||
}
|
||||
with self.assertRaisesRegex(RuntimeAdapterError, "profile does not contact material"):
|
||||
runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face})
|
||||
fragment["sketch"]["workplane"]["origin_mm"][2] = 16
|
||||
runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face})
|
||||
|
||||
def test_surface_attached_cut_direction_is_normalized_into_material(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cut_direction",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "sketch_001",
|
||||
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
}]},
|
||||
"features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}],
|
||||
}
|
||||
fragment = {
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [6, 0], "radius_mm": 1},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}},
|
||||
}
|
||||
top_face = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 5], "normal": [0, 0, 1],
|
||||
"boundary_loops_mm": [[[-10, -10, 5], [10, -10, 5], [10, 10, 5], [-10, 10, 5]]],
|
||||
},
|
||||
}
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
fragment,
|
||||
runtime.operation_contract("extrude_cut_blind"),
|
||||
{"top": top_face},
|
||||
runtime.reference_tokens(base),
|
||||
)
|
||||
self.assertTrue(document["features"][-1]["params"]["reverse"])
|
||||
direction = next(item for item in audit["server_normalizations"] if item["path"] == "feature.params.reverse")
|
||||
self.assertFalse(direction["submitted"])
|
||||
self.assertTrue(direction["materialized"])
|
||||
|
||||
def test_sketch_workplane_candidates_prefer_the_broad_base_support(self) -> None:
|
||||
candidates = WorkflowCoordinator._sketch_workplane_candidates({"records": [
|
||||
{
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
|
||||
"bbox_mm": [-48, -48, 34, 48, 48, 34],
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1],
|
||||
"bbox_mm": [-90, -50, 16, 90, 50, 16],
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [90, 0, 8], "normal": [1, 0, 0],
|
||||
"bbox_mm": [90, -50, 0, 90, 50, 16],
|
||||
},
|
||||
},
|
||||
]})
|
||||
self.assertEqual(candidates[0]["point_mm"], [0.0, 0.0, 16.0])
|
||||
self.assertEqual(candidates[0]["footprint_bbox_area_mm2"], 18000.0)
|
||||
self.assertEqual(candidates[1]["point_mm"], [0.0, 0.0, 34.0])
|
||||
|
||||
def test_replan_budget_spans_replacement_node_ids_at_one_checkpoint(self) -> None:
|
||||
events = [
|
||||
{
|
||||
"event": "feature_node_failed", "node_id": node_id,
|
||||
"atomic_id": "extrude_cut_blind", "checkpoint_revision": "rev_005", "terminal": True,
|
||||
}
|
||||
for node_id in ("corner_slots_v1", "corner_slots_v2", "corner_slots_v3")
|
||||
]
|
||||
|
||||
class Repository:
|
||||
@staticmethod
|
||||
def ledger_events(_task_id: str) -> list[dict[str, object]]:
|
||||
return events
|
||||
|
||||
workflow = object.__new__(WorkflowCoordinator)
|
||||
workflow.repository = Repository()
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.REPLANNING_FEATURE_SUBGRAPH, 10, active_revision="rev_005")
|
||||
exhausted = workflow._feature_replan_exhausted(state.task_id, state)
|
||||
self.assertIsNotNone(exhausted)
|
||||
self.assertEqual(exhausted["terminal_failure_count"], 3)
|
||||
self.assertEqual(exhausted["node_ids"], ["corner_slots_v1", "corner_slots_v2", "corner_slots_v3"])
|
||||
|
||||
def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
@@ -445,7 +637,10 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
|
||||
@@ -467,18 +662,125 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]]
|
||||
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "visual"])
|
||||
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "visual"])
|
||||
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "concentric_bore_to_outer_cylinder", "visual"])
|
||||
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "deterministic", "visual"])
|
||||
self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"]))
|
||||
self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"]))
|
||||
|
||||
def test_unbacked_coaxial_bore_group_is_not_frozen_as_a_deterministic_claim(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 40, "count": 2, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
]})
|
||||
normalized, warnings = handler._normalize_compiled_spec(output, ["A through bore.", "The bore is concentric with the outer profile."])
|
||||
self.assertEqual(normalized.requirements[1].acceptance_claims[0].claim_kind, "visual")
|
||||
self.assertTrue(any("no matching multi-bore target" in warning for warning in warnings))
|
||||
|
||||
def test_obround_slot_is_not_compiled_as_a_corner_bore_pattern(self) -> None:
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [{
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [{
|
||||
"claim_kind": "rectangular_corner_through_bore_pattern",
|
||||
"expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 26, "tolerance_mm": 0.1},
|
||||
}],
|
||||
}]})
|
||||
normalized, warnings = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec(
|
||||
output,
|
||||
["Four 26 x 9 mm oblong adjustment slots are present at the four corners."],
|
||||
)
|
||||
claim = normalized.requirements[0].acceptance_claims[0]
|
||||
self.assertEqual(claim.claim_kind, "visual")
|
||||
self.assertEqual(claim.expected["description"], "Four 26 x 9 mm oblong adjustment slots are present at the four corners.")
|
||||
self.assertTrue(any("describes an obround slot" in warning for warning in warnings))
|
||||
|
||||
def test_centered_bore_checklist_item_requires_concentric_claim_coverage(self) -> None:
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [{
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}],
|
||||
}]})
|
||||
errors = RequirementsCommandHandler._relationship_claim_errors(
|
||||
output,
|
||||
["A centered 40 mm through bore is present."],
|
||||
)
|
||||
self.assertEqual(errors[0]["path"], "/requirements/0/acceptance_claims")
|
||||
output.requirements[0].acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {"bore_diameter_mm": 40, "outer_diameter_mm": 120, "tolerance_mm": 0.01},
|
||||
}))
|
||||
self.assertEqual(
|
||||
RequirementsCommandHandler._relationship_claim_errors(output, ["A centered 40 mm through bore is present."]),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_compiler_derives_concentric_claim_from_frozen_outer_cylinder_and_centered_bore(self) -> None:
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{
|
||||
"claim_kind": "outer_cylindrical_surface",
|
||||
"expected": {"diameter_mm": 120, "axial_span_mm": 12, "count": 1, "tolerance_mm": 0.01},
|
||||
}]},
|
||||
{"assumptions": [], "acceptance_claims": [{
|
||||
"claim_kind": "through_cylindrical_bore",
|
||||
"expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01},
|
||||
}]},
|
||||
]})
|
||||
normalized, _ = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec(
|
||||
output,
|
||||
["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."],
|
||||
)
|
||||
derived = normalized.requirements[1].acceptance_claims[-1]
|
||||
self.assertEqual(derived.claim_kind, "concentric_bore_to_outer_cylinder")
|
||||
self.assertEqual(derived.expected, {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01})
|
||||
self.assertEqual(
|
||||
RequirementsCommandHandler._relationship_claim_errors(
|
||||
normalized,
|
||||
["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."],
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_concentric_bore_to_outer_cylinder_verifier_measures_axis_offset(self) -> None:
|
||||
outer = {
|
||||
"record_id": "outer", "geometry": {
|
||||
"surface_type": "cylinder", "cylinder_role": "outer", "radius_mm": 60.0,
|
||||
"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0],
|
||||
"bbox_mm": [-60.0, -60.0, 0.0, 60.0, 60.0, 12.0],
|
||||
},
|
||||
}
|
||||
bore = {
|
||||
"record_id": "bore", "geometry": {
|
||||
"surface_type": "cylinder", "cylinder_role": "inner", "radius_mm": 20.0,
|
||||
"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0],
|
||||
"bbox_mm": [-20.0, -20.0, 0.0, 20.0, 20.0, 12.0], "through": True,
|
||||
},
|
||||
}
|
||||
expected = {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01}
|
||||
registry = default_registry()
|
||||
result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}})
|
||||
self.assertEqual(result["status"], "pass")
|
||||
bore["geometry"]["axis_origin_mm"] = [0.1, 0.0, 0.0]
|
||||
result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}})
|
||||
self.assertEqual(result["status"], "fail")
|
||||
self.assertAlmostEqual(result["evidence"]["axis_distance_mm"], 0.1)
|
||||
|
||||
def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
@@ -572,6 +874,56 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID)
|
||||
self.assertEqual(repository.get_state(task_id), before)
|
||||
|
||||
def test_feature_plan_rejection_has_an_independent_retry_budget(self) -> None:
|
||||
"""A failed replan must not consume a prior requirements-format retry."""
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
runtime = ProfileCadRuntime(settings(root))
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry()))
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(max_turns=8, format_error_limit=2),
|
||||
repository,
|
||||
artifacts,
|
||||
runtime,
|
||||
object(), # The direct retry-budget test does not call a model.
|
||||
object(),
|
||||
requirements,
|
||||
actions,
|
||||
)
|
||||
initial = repository.create_task("cad_123456abcdef", "Create a plate.")
|
||||
documented = transition(initial, "requirements_document_written", requirements_document_path="requirements.md")
|
||||
targeted = transition(documented, "completion_target_written", completion_target_path="completion-target.md")
|
||||
compiling = transition(targeted, "requirements_compiled", requirements_contract_path="requirements-contract.json")
|
||||
scheduled = transition(
|
||||
compiling,
|
||||
"feature_plan_written",
|
||||
feature_plan_path="plans/feature-plan-active.json",
|
||||
feature_plan_hash="a" * 64,
|
||||
)
|
||||
replanning = transition(scheduled, "feature_replan", error=ErrorCode.CANDIDATE_REVIEW_REJECTED)
|
||||
self.assertTrue(repository.compare_and_swap(documented))
|
||||
self.assertTrue(repository.compare_and_swap(targeted))
|
||||
self.assertTrue(repository.compare_and_swap(compiling))
|
||||
self.assertTrue(repository.compare_and_swap(scheduled))
|
||||
self.assertTrue(repository.compare_and_swap(replanning))
|
||||
counters = {"requirements_spec": 1}
|
||||
feedback: list[dict[str, object]] = []
|
||||
terminal = workflow._requirements_rejection(
|
||||
replanning.task_id,
|
||||
replanning,
|
||||
WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan violates immutable-node rules."),
|
||||
counters,
|
||||
feedback,
|
||||
tool="write_feature_plan",
|
||||
)
|
||||
self.assertIsNone(terminal)
|
||||
self.assertEqual(repository.get_state(replanning.task_id).phase, TaskPhase.REPLANNING_FEATURE_SUBGRAPH)
|
||||
self.assertEqual(counters, {"requirements_spec": 1, "write_feature_plan": 1})
|
||||
self.assertEqual(len(feedback), 1)
|
||||
|
||||
def test_completion_result_reports_frozen_checklist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.llm_contracts import CompiledRequirementsSpec, MarkdownDocument
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
def runtime_settings(root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://test.invalid/v1", "key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks", conversation_root=root / "conversations",
|
||||
library_root=ROOT / "backend" / "cdsl_library", engine_root=ROOT / "backend" / "engine" / "cdsl_engine",
|
||||
llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1,
|
||||
default_provider_id="test", providers=(provider,),
|
||||
)
|
||||
|
||||
|
||||
def contract() -> dict[str, object]:
|
||||
return {
|
||||
"requirements": [
|
||||
{
|
||||
"requirement_id": "req_001",
|
||||
"acceptance_claims": [
|
||||
{"claim_id": "claim_base", "verification_mode": "deterministic"},
|
||||
{"claim_id": "claim_visual", "verification_mode": "visual"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_002",
|
||||
"acceptance_claims": [{"claim_id": "claim_bore", "verification_mode": "deterministic"}],
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_003",
|
||||
"acceptance_claims": [{"claim_id": "claim_pattern", "verification_mode": "deterministic"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def initial_plan() -> FeaturePlan:
|
||||
return FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1",
|
||||
"parent_plan_hash": "",
|
||||
"replaces_node_ids": [],
|
||||
"nodes": [
|
||||
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_base"], "expected_change": "One base solid."},
|
||||
{"node_id": "bore", "priority": 20, "intent": "Cut the bore.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
|
||||
{"node_id": "pattern", "priority": 30, "intent": "Add the pattern.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "Mounting holes."},
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
|
||||
|
||||
class FeaturePlanTests(unittest.TestCase):
|
||||
def test_plan_requires_exact_claim_ownership(self) -> None:
|
||||
plan = initial_plan()
|
||||
self.assertEqual(validate_feature_plan(plan, contract(), {"extrude_add_blind", "hole_blind"}), [])
|
||||
broken = plan.model_copy(deep=True)
|
||||
broken.nodes[1].claim_ids = ["claim_base"]
|
||||
messages = [item["message"] for item in validate_feature_plan(broken, contract(), {"extrude_add_blind", "hole_blind"})]
|
||||
self.assertTrue(any("already owned" in message for message in messages))
|
||||
self.assertTrue(any("has no owner" in message for message in messages))
|
||||
|
||||
def test_visual_claim_repeated_on_a_node_is_removed_before_persisting_the_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=lambda: ("extrude_add_blind", "hole_blind"))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a part.")
|
||||
artifacts.initialize_task(task_id, "Create a part.")
|
||||
self.assertIsInstance(handler.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a part."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(handler.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] A base and a visual edge treatment."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "visual", "expected": {"description": "A visible edge treatment."}},
|
||||
]}]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
submitted = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One base solid."}],
|
||||
"final_claim_ids": ["claim_002"],
|
||||
})
|
||||
accepted = handler.submit_feature_plan(task_id, submitted, invocation_id="plan")
|
||||
self.assertIsInstance(accepted, Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
persisted = artifacts.read_json(task_id, state.feature_plan_path)
|
||||
self.assertEqual(persisted["nodes"][0]["claim_ids"], ["claim_001"])
|
||||
self.assertEqual(persisted["final_claim_ids"], ["claim_002"])
|
||||
|
||||
def test_unowned_global_health_claim_is_bound_to_the_unique_root_add_feature(self) -> None:
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [
|
||||
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_bore"], "expected_change": "One base solid."},
|
||||
{"node_id": "finish", "priority": 20, "intent": "Finish the part.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "One bore."},
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
health_contract = contract()
|
||||
health_contract["requirements"][0]["acceptance_claims"][0]["claim_kind"] = "single_connected_body"
|
||||
normalized = RequirementsCommandHandler._assign_unowned_global_health_claims(plan, health_contract)
|
||||
self.assertEqual(normalized.nodes[0].claim_ids, ["claim_bore", "claim_base"])
|
||||
self.assertEqual(validate_feature_plan(normalized, health_contract, {"extrude_add_blind", "hole_blind"}), [])
|
||||
|
||||
def test_scheduler_uses_ready_nodes_and_fixed_priority(self) -> None:
|
||||
plan = initial_plan()
|
||||
scheduler = FeatureScheduler(plan, [])
|
||||
self.assertEqual(scheduler.next_ready().node_id, "base")
|
||||
base = plan.nodes[0]
|
||||
events = [{
|
||||
"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base),
|
||||
"feature_id": "feature_001", "revision_id": "rev_001",
|
||||
}]
|
||||
scheduler = FeatureScheduler(plan, events)
|
||||
self.assertEqual(scheduler.statuses()["base"], "done")
|
||||
# Both bore and pattern are ready; priority decides deterministically.
|
||||
self.assertEqual(scheduler.next_ready().node_id, "bore")
|
||||
self.assertEqual(scheduler.feature_ids(), {"base": "feature_001"})
|
||||
|
||||
def test_revision_cannot_change_done_node_and_replaces_failed_subgraph(self) -> None:
|
||||
previous = initial_plan()
|
||||
base = previous.nodes[0]
|
||||
bore = previous.nodes[1]
|
||||
events = [
|
||||
{"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base), "feature_id": "feature_001", "revision_id": "rev_001"},
|
||||
{"event": "feature_node_failed", "node_id": "bore", "node_hash": node_hash(bore), "failure_class": "engine_build", "attempt": 2, "terminal": True},
|
||||
]
|
||||
completed = FeatureScheduler(previous, events).completed_node_hashes()
|
||||
revision = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1",
|
||||
"parent_plan_hash": plan_hash(previous),
|
||||
"replaces_node_ids": ["bore"],
|
||||
"nodes": [
|
||||
base.model_dump(mode="json"),
|
||||
{"node_id": "bore_revised", "priority": 20, "intent": "Cut the bore with revised operation.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
|
||||
previous.nodes[2].model_dump(mode="json"),
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
self.assertEqual(
|
||||
validate_feature_plan(revision, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"}),
|
||||
[],
|
||||
)
|
||||
changed = revision.model_copy(deep=True)
|
||||
changed.nodes[0].intent = "Changed completed node."
|
||||
self.assertTrue(any("completed node 'base' was modified" in item["message"] for item in validate_feature_plan(changed, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"})))
|
||||
|
||||
def test_materialized_feature_uses_direct_dag_dependencies_not_previous_history_item(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "dag_test",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "sketch_001", "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"},
|
||||
{"id": "feature_002", "atomic_id": "reference_plane", "params": {"plane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}}, "depends_on": ["feature_001"]},
|
||||
],
|
||||
}
|
||||
contract = runtime.operation_contract("reference_axis")
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
{"feature": {"atomic_id": "reference_axis", "params": {"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}}},
|
||||
contract,
|
||||
{},
|
||||
runtime.reference_tokens(base),
|
||||
depends_on_feature_ids=("feature_001",),
|
||||
)
|
||||
self.assertEqual(document["features"][-1]["depends_on"], ["feature_001"])
|
||||
self.assertEqual(audit["depends_on_feature_ids"], ["feature_001"])
|
||||
|
||||
def test_required_through_cut_gets_a_server_recorded_exit_allowance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "through_cut",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "sketch_001",
|
||||
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
}]},
|
||||
"features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}],
|
||||
}
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
{
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 2},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5, "reverse": True}},
|
||||
},
|
||||
runtime.operation_contract("extrude_cut_blind"),
|
||||
{"body": {"kind": "body", "geometry": {"bbox_mm": [-10, -10, 0, 10, 10, 5]}}},
|
||||
runtime.reference_tokens(base),
|
||||
require_through=True,
|
||||
depends_on_feature_ids=("feature_001",),
|
||||
)
|
||||
self.assertAlmostEqual(document["features"][-1]["params"]["distance_mm"], 5.01)
|
||||
self.assertEqual(audit["server_normalizations"][0]["submitted_mm"], 5.0)
|
||||
self.assertEqual(audit["server_normalizations"][0]["reason"], "required through-cut exit allowance")
|
||||
|
||||
def test_verified_node_publishes_without_a_render_bundle_or_candidate_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
registry = default_registry()
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One connected disk with 20 mm diameter and 5 mm thickness."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "axial_span_mm": 5, "tolerance_mm": 0.1}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.SCHEDULING_FEATURE)
|
||||
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
|
||||
result = actions.submit_feature_fragment(task_id, {
|
||||
"sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
|
||||
}, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
self.assertEqual(state.phase, TaskPhase.SCHEDULING_FEATURE)
|
||||
self.assertEqual(state.active_revision, "rev_001")
|
||||
self.assertFalse((artifacts.task_dir(task_id) / "revisions" / "rev_001" / "renders").exists())
|
||||
events = repository.ledger_events(task_id)
|
||||
self.assertTrue(any(event.get("event") == "feature_node_verified" for event in events))
|
||||
self.assertFalse(any(event.get("event") == "candidate_built" for event in events))
|
||||
final_gate = actions.schedule_next_feature(task_id)
|
||||
self.assertIsInstance(final_gate, Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FINAL_VALIDATION)
|
||||
self.assertTrue(any(event.get("event") == "feature_plan_complete" for event in repository.ledger_events(task_id)))
|
||||
|
||||
def test_feature_plan_schema_binds_plan_revision_lineage_to_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
scheduled = transition(repository.get_state(task_id), "feature_scheduled")
|
||||
self.assertTrue(repository.compare_and_swap(scheduled))
|
||||
replanning = transition(repository.get_state(task_id), "feature_replan")
|
||||
self.assertTrue(repository.compare_and_swap(replanning))
|
||||
schema = requirements.feature_plan_schema(task_id)
|
||||
properties = schema["properties"]
|
||||
self.assertEqual(properties["parent_plan_hash"]["enum"], [plan_hash(plan)])
|
||||
self.assertEqual(properties["replaces_node_ids"]["minItems"], 0)
|
||||
self.assertEqual(properties["replaces_node_ids"]["maxItems"], 0)
|
||||
node_schema = schema["$defs"]["FeatureNode"]["properties"]
|
||||
self.assertEqual(node_schema["claim_ids"]["items"], {"enum": ["claim_001"]})
|
||||
self.assertEqual(properties["final_claim_ids"]["items"], {"enum": []})
|
||||
|
||||
def test_pending_assigned_claim_rejects_the_node_checkpoint(self) -> None:
|
||||
class PendingAssignedClaimVerifier:
|
||||
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"claim_id": str(claim.get("claim_id") or ""),
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"deterministic": True,
|
||||
"status": "pending" if claim.get("claim_id") == "claim_002" else "pass",
|
||||
"evidence": {"reason": "target geometry has not been introduced"},
|
||||
}
|
||||
for claim in claims
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, PendingAssignedClaimVerifier())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "tolerance_mm": 0.1}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
|
||||
result = actions.submit_feature_fragment(task_id, {
|
||||
"sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
|
||||
}, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Rejected)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
|
||||
events = repository.ledger_events(task_id)
|
||||
failure = next(event for event in reversed(events) if event.get("event") == "feature_node_failed")
|
||||
self.assertEqual(failure["failure_class"], "node_validation")
|
||||
self.assertTrue(any(item.get("claim_id") == "claim_002" and item.get("status") == "pending" for item in failure["blockers"]))
|
||||
self.assertFalse(any(event.get("event") == "feature_node_verified" for event in events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user