优化流程
This commit is contained in:
@@ -109,8 +109,8 @@ class ActionCommandHandler:
|
||||
for event in self.repository.ledger_events(task_id)[-16:]:
|
||||
if event.get("event") in {
|
||||
"candidate_rejected", "candidate_build_failed", "candidate_recovery_failed",
|
||||
"candidate_recovered_rejected", "final_review_repair",
|
||||
}:
|
||||
"candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair",
|
||||
} or (event.get("event") == "accepted" and event.get("repair_required")):
|
||||
sequence = event.get("sequence")
|
||||
if isinstance(sequence, int):
|
||||
refs.append(f"evidence_ledger_{sequence}")
|
||||
@@ -134,8 +134,10 @@ class ActionCommandHandler:
|
||||
"candidate_build_failed",
|
||||
"candidate_recovery_failed",
|
||||
"candidate_recovered_rejected",
|
||||
"candidate_operation_skipped",
|
||||
"runtime_precondition_rejected",
|
||||
"final_review_repair",
|
||||
"accepted",
|
||||
}
|
||||
diagnostics: list[dict[str, Any]] = []
|
||||
for event in reversed(self.repository.ledger_events(task_id)):
|
||||
@@ -153,6 +155,20 @@ class ActionCommandHandler:
|
||||
message = event.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
item["message"] = message[:500]
|
||||
for field in ("issues", "failed_checklist_items"):
|
||||
values = event.get(field)
|
||||
if isinstance(values, list):
|
||||
item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)]
|
||||
failures = event.get("operation_failures")
|
||||
if isinstance(failures, list):
|
||||
item["operation_failures"] = [
|
||||
{key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}}
|
||||
for failure in failures[:8]
|
||||
if isinstance(failure, dict)
|
||||
]
|
||||
fragment_hash = event.get("fragment_hash")
|
||||
if isinstance(fragment_hash, str) and fragment_hash:
|
||||
item["fragment_hash"] = fragment_hash
|
||||
stage_id = event.get("stage_id")
|
||||
candidate = None
|
||||
if isinstance(stage_id, str) and stage_id:
|
||||
@@ -263,7 +279,11 @@ class ActionCommandHandler:
|
||||
state = state or self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
|
||||
return False
|
||||
return any(
|
||||
has_earlier_checkpoint = any(
|
||||
revision != state.active_revision
|
||||
for revision in self.checkpoint_tokens(task_id, state).values()
|
||||
)
|
||||
return has_earlier_checkpoint and any(
|
||||
event.get("event") == "geometry_conclusion"
|
||||
and event.get("decision") == "rollback"
|
||||
and event.get("working_head") == state.working_head
|
||||
@@ -390,6 +410,7 @@ class ActionCommandHandler:
|
||||
fragment,
|
||||
selector_tokens=allowed_selector_tokens,
|
||||
reference_tokens=list(references),
|
||||
root_xy_datum=not bool(state.active_revision),
|
||||
)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors)))
|
||||
@@ -452,8 +473,48 @@ class ActionCommandHandler:
|
||||
if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]):
|
||||
return Rejected(self._stale())
|
||||
try:
|
||||
rebuilt = self.runtime.rebuild(cdsl, stage.output_dir, task_id, candidate_id)
|
||||
claim_results = self._evaluate_claims(task_id, rebuilt)
|
||||
rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id)
|
||||
attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)}
|
||||
executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)}
|
||||
if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids):
|
||||
next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED)
|
||||
result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures}
|
||||
if not self._commit_invocation(next_state, [{
|
||||
"event": "candidate_operation_skipped",
|
||||
"candidate_id": candidate_id,
|
||||
"stage_id": stage.stage_id,
|
||||
"action_id": action.action_id,
|
||||
"working_head": action.working_head,
|
||||
"checkpoint_revision": state.active_revision,
|
||||
"atomic_id": action.atomic_id,
|
||||
"fragment_hash": fragment_hash,
|
||||
"operation_failures": operation_failures,
|
||||
"message": "The submitted feature did not execute; earlier executable features were retained.",
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(
|
||||
ErrorCode.CANDIDATE_BUILD_FAILED,
|
||||
"The submitted feature could not execute; the previous executable checkpoint was retained.",
|
||||
details={"operation_failures": operation_failures},
|
||||
))
|
||||
try:
|
||||
claim_results = self._evaluate_claims(task_id, rebuilt)
|
||||
except Exception as error:
|
||||
failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID)
|
||||
result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value}
|
||||
if not self._commit_invocation(failed_state, [{
|
||||
"event": "requirements_contract_execution_failed",
|
||||
"candidate_id": candidate_id,
|
||||
"stage_id": stage.stage_id,
|
||||
"action_id": action.action_id,
|
||||
"message": str(error)[:1000],
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(
|
||||
ErrorCode.REQUIREMENTS_SPEC_INVALID,
|
||||
"The frozen requirements contract could not be evaluated; no CAD repair was attempted.",
|
||||
details={"diagnostic": str(error)[:1000]},
|
||||
))
|
||||
operation_results = self._operation_candidate_results(
|
||||
action,
|
||||
contract,
|
||||
@@ -466,47 +527,11 @@ class ActionCommandHandler:
|
||||
*self._candidate_blockers(task_id, action.requirement_ids, claim_results),
|
||||
*[item for item in operation_results if item.get("status") != "pass"],
|
||||
]
|
||||
candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]}
|
||||
candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]}
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate)
|
||||
if blockers:
|
||||
failure_class_fingerprint = self._failure_class_fingerprint(
|
||||
state.active_revision, action.atomic_id, ErrorCode.CLAIM_VERIFICATION_FAILED,
|
||||
)
|
||||
prior_failures = self._failure_class_events(task_id, failure_class_fingerprint)
|
||||
if len(prior_failures) >= 2:
|
||||
failed_state = transition(building, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value}
|
||||
if not self._commit_invocation(failed_state, [{
|
||||
"event": "no_progress_limit", "candidate_id": candidate_id, "stage_id": stage.stage_id,
|
||||
"action_id": action.action_id, "checkpoint_revision": state.active_revision,
|
||||
"atomic_id": action.atomic_id, "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value,
|
||||
"failure_class_fingerprint": failure_class_fingerprint,
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same deterministic candidate failure made no progress after three attempts."))
|
||||
failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED)
|
||||
result = {"candidate_id": candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results}
|
||||
if not self._commit_invocation(failed_state, [{
|
||||
"event": "candidate_rejected",
|
||||
"candidate_id": candidate_id,
|
||||
"stage_id": stage.stage_id,
|
||||
"action_id": action.action_id,
|
||||
"working_head": action.working_head,
|
||||
"checkpoint_revision": state.active_revision,
|
||||
"atomic_id": action.atomic_id,
|
||||
"fragment_hash": fragment_hash,
|
||||
"normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value,
|
||||
"failure_exact_fingerprint": exact_fingerprint,
|
||||
"failure_class_fingerprint": failure_class_fingerprint,
|
||||
"attempt": len(prior_failures) + 1,
|
||||
"reason": "deterministic_claim_failed",
|
||||
"claim_results": claim_results,
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "Candidate violates a deterministic claim required by this action or a global invariant.", details={"claim_results": blockers}))
|
||||
review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id)
|
||||
result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results}
|
||||
if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result):
|
||||
result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures}
|
||||
if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
except OSError as error:
|
||||
@@ -592,7 +617,9 @@ class ActionCommandHandler:
|
||||
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
|
||||
references = self.runtime.reference_tokens(base)
|
||||
cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids))
|
||||
rebuilt = self.runtime.rebuild(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id)
|
||||
rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id)
|
||||
if "operation_failures" not in locals():
|
||||
operation_failures = []
|
||||
claim_results = self._evaluate_claims(task_id, rebuilt)
|
||||
audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {}
|
||||
contract = self.runtime.operation_contract(action.atomic_id)
|
||||
@@ -611,6 +638,7 @@ class ActionCommandHandler:
|
||||
"fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}),
|
||||
"selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results,
|
||||
"operation_verifier_results": operation_results,
|
||||
"operation_failures": operation_failures,
|
||||
"blockers": [
|
||||
*self._candidate_blockers(task_id, action.requirement_ids, claim_results),
|
||||
*[item for item in operation_results if item.get("status") != "pass"],
|
||||
@@ -699,51 +727,9 @@ class ActionCommandHandler:
|
||||
event="candidate_review_storage_failure",
|
||||
message=str(error),
|
||||
)
|
||||
if review.verdict != "accept" or deterministic_fail:
|
||||
if published_revision:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "A published candidate cannot be rejected during recovery.", retryable=True))
|
||||
failure_class_fingerprint = self._failure_class_fingerprint(
|
||||
state.active_revision, action.atomic_id, ErrorCode.CANDIDATE_REVIEW_REJECTED,
|
||||
)
|
||||
reviewed_fragment_hash = str(candidate.get("fragment_hash") or "")
|
||||
failure_exact_fingerprint = canonical_hash({
|
||||
"active_revision": state.active_revision,
|
||||
"atomic_id": action.atomic_id,
|
||||
"fragment_hash": reviewed_fragment_hash,
|
||||
}) if reviewed_fragment_hash else ""
|
||||
prior_failures = self._failure_class_events(task_id, failure_class_fingerprint)
|
||||
if len(prior_failures) >= 2:
|
||||
next_state = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
result = {"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value}
|
||||
if not self._commit_invocation(next_state, [{
|
||||
"event": "no_progress_limit", "candidate_id": state.candidate_id,
|
||||
"stage_id": state.candidate_stage_id, "action_id": action.action_id,
|
||||
"checkpoint_revision": state.active_revision, "atomic_id": action.atomic_id,
|
||||
"normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
|
||||
"failure_class_fingerprint": failure_class_fingerprint,
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same candidate-review failure made no progress after three attempts."))
|
||||
next_state = transition(state, "candidate_rejected", error=ErrorCode.CANDIDATE_REVIEW_REJECTED, repair_required=True)
|
||||
result = {"candidate_id": state.candidate_id, "status": "rejected"}
|
||||
if not self._commit_invocation(next_state, [{
|
||||
"event": "candidate_rejected",
|
||||
"candidate_id": state.candidate_id,
|
||||
"stage_id": state.candidate_stage_id,
|
||||
"action_id": action.action_id,
|
||||
"working_head": action.working_head,
|
||||
"review_path": "candidate-review.json",
|
||||
"deterministic_fail": deterministic_fail,
|
||||
"checkpoint_revision": state.active_revision,
|
||||
"atomic_id": action.atomic_id,
|
||||
"fragment_hash": reviewed_fragment_hash,
|
||||
"failure_exact_fingerprint": failure_exact_fingerprint,
|
||||
"normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
|
||||
"failure_class_fingerprint": failure_class_fingerprint,
|
||||
"attempt": len(prior_failures) + 1,
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else []
|
||||
candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else []
|
||||
needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers)
|
||||
revision_id = self._next_revision(task_id)
|
||||
if published_revision and published_revision != revision_id:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True))
|
||||
@@ -755,9 +741,9 @@ class ActionCommandHandler:
|
||||
event="candidate_publish_storage_failure",
|
||||
message=str(error),
|
||||
)
|
||||
next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=False)
|
||||
event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual}
|
||||
result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted"}
|
||||
next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None)
|
||||
event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair}
|
||||
result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"}
|
||||
if not self._commit_invocation(next_state, [event], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
@@ -833,21 +819,6 @@ class ActionCommandHandler:
|
||||
invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None
|
||||
if invocation is not None and invocation.status == "finished" and invocation.result is not None:
|
||||
return Accepted(invocation.result)
|
||||
if blockers:
|
||||
failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED)
|
||||
result = {"candidate_id": state.candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results}
|
||||
event = {
|
||||
"event": "candidate_recovered_rejected",
|
||||
"candidate_id": state.candidate_id,
|
||||
"stage_id": state.candidate_stage_id,
|
||||
"action_id": action.action_id,
|
||||
"working_head": action.working_head,
|
||||
"claim_results": claim_results,
|
||||
}
|
||||
committed = self._commit_invocation(failed, [event], invocation, result) if invocation is not None else self.repository.compare_and_swap(failed, events=[event])
|
||||
if not committed:
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id)
|
||||
result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results}
|
||||
committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}])
|
||||
@@ -855,6 +826,43 @@ class ActionCommandHandler:
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
|
||||
def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Publish the last executable checkpoint when further repair is bounded.
|
||||
|
||||
This is deliberately separate from strict final validation. It does
|
||||
not claim that unmet acceptance targets passed; it makes the usable
|
||||
model and its measured gaps durable instead of converting a planning
|
||||
dead-end into a failed task with no deliverable.
|
||||
"""
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or not state.active_revision:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint."))
|
||||
if state.phase == TaskPhase.COMPLETED:
|
||||
return Accepted({"status": "completed", "revision_id": state.active_revision})
|
||||
key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value})
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return Accepted(invocation.result)
|
||||
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
|
||||
issues = [
|
||||
f"{item.get('claim_kind')}: {item.get('status')}"
|
||||
for item in claim_results
|
||||
if item.get("status") != "pass"
|
||||
]
|
||||
next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False)
|
||||
result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues}
|
||||
if not self._commit_invocation(next_state, [{
|
||||
"event": "completed_best_effort",
|
||||
"revision_id": state.active_revision,
|
||||
"termination_code": reason.value,
|
||||
"message": "Further CAD repair was bounded; the last executable checkpoint was published.",
|
||||
"issues": issues,
|
||||
"claim_results": claim_results,
|
||||
"completion_result_path": "completion-result.md",
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
|
||||
def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.FINAL_VALIDATION:
|
||||
@@ -901,6 +909,19 @@ class ActionCommandHandler:
|
||||
message=str(error),
|
||||
)
|
||||
if review.verdict != "pass" or deterministic_fail or visual_not_passed:
|
||||
accepted_fragment = next((
|
||||
event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id))
|
||||
if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision
|
||||
), "")
|
||||
atomic_id = next((
|
||||
str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id))
|
||||
if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision
|
||||
), "")
|
||||
exact_fingerprint = canonical_hash({
|
||||
"active_revision": state.active_revision,
|
||||
"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)
|
||||
result = {"status": "repair", "revision_id": state.active_revision}
|
||||
if not self._commit_invocation(next_state, [{
|
||||
@@ -909,6 +930,18 @@ class ActionCommandHandler:
|
||||
"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],
|
||||
"issues": list(review.issues),
|
||||
"evidence": list(review.evidence),
|
||||
"failed_checklist_items": [
|
||||
str(requirement.get("statement") or "")
|
||||
for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
|
||||
],
|
||||
"atomic_id": atomic_id,
|
||||
"fragment_hash": accepted_fragment,
|
||||
"failure_exact_fingerprint": exact_fingerprint,
|
||||
"normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
|
||||
}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
|
||||
@@ -9,12 +9,12 @@ from typing import Any, Literal
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
EmptyCommand,
|
||||
ImageObservation,
|
||||
RequirementsAuthorOutput,
|
||||
MarkdownDocument,
|
||||
StatelessCandidateReview,
|
||||
StatelessGeometryConclusion,
|
||||
StatelessRollbackCheckpoint,
|
||||
StatelessTopologyRequest,
|
||||
requirements_spec_schema,
|
||||
compiled_requirements_schema,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
@@ -37,7 +37,10 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
|
||||
if not atomic_ids:
|
||||
raise RuntimeError("Runtime has no operations for conformance")
|
||||
tools = [
|
||||
_tool("submit_requirements_spec", requirements_spec_schema(default_registry().expected_one_of_schema())),
|
||||
_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_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()),
|
||||
@@ -54,7 +57,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.spec.v1", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,28 @@ class SpecRequirementInput(StrictDto):
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class MarkdownDocument(StrictDto):
|
||||
"""A frozen human-readable design artifact, never an executable payload."""
|
||||
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
|
||||
|
||||
|
||||
class CompiledRequirementInput(StrictDto):
|
||||
"""One verifier bundle for one server-parsed checklist item.
|
||||
|
||||
The checklist text, ordering, source bindings, and all identifiers are
|
||||
intentionally absent: the service owns them after Markdown is frozen.
|
||||
"""
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CompiledRequirementsSpec(StrictDto):
|
||||
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
# Kept only so an interrupted process with an already imported old tool schema
|
||||
# fails at the workflow boundary instead of failing module import. New v3.1
|
||||
# tasks never expose or accept this aggregate specification.
|
||||
class RequirementsSpec(StrictDto):
|
||||
outcome: Literal["ready"]
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
@@ -61,7 +83,9 @@ class EmptyCommand(StrictDto):
|
||||
class NextAction(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
intent: ShortText
|
||||
requirement_ids: list[Identifier] = Field(min_length=1, max_length=5)
|
||||
# The server binds this list from every frozen checklist target. It is not
|
||||
# author input, so a five-item UI-era limit must not reject a valid task.
|
||||
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
|
||||
atomic_id: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
@@ -194,6 +218,66 @@ def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
||||
return schema
|
||||
|
||||
|
||||
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
|
||||
schema = CompiledRequirementsSpec.model_json_schema()
|
||||
definitions = schema.get("$defs", {})
|
||||
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
requirements = schema.get("properties", {}).get("requirements")
|
||||
if isinstance(requirements, dict):
|
||||
requirements["minItems"] = target_count
|
||||
requirements["maxItems"] = target_count
|
||||
return schema
|
||||
|
||||
|
||||
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
|
||||
"""Drop harmless compiler chatter before strict requirements validation.
|
||||
|
||||
``compile_requirements_spec`` is a compiler stage: the service only needs
|
||||
the ordered verifier bundles for the frozen checklist items. Real models
|
||||
sometimes add explanatory fields such as a top-level ``assumptions`` or
|
||||
per-item ``statement`` even when the dynamic tool schema forbids them. Those
|
||||
fields are not executable and are not part of the frozen contract, so they
|
||||
should not abort a task before modeling starts.
|
||||
|
||||
The verifier ``expected`` payload is intentionally not sanitized here. It
|
||||
remains governed by the registry's strict per-claim schema because those
|
||||
values drive deterministic validation.
|
||||
"""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
requirements = value.get("requirements")
|
||||
sanitized: dict[str, Any] = {}
|
||||
if isinstance(requirements, list):
|
||||
sanitized_requirements: list[Any] = []
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, dict):
|
||||
sanitized_requirements.append(requirement)
|
||||
continue
|
||||
item: dict[str, Any] = {}
|
||||
if "assumptions" in requirement:
|
||||
item["assumptions"] = requirement["assumptions"]
|
||||
if "acceptance_claims" in requirement:
|
||||
claims = requirement["acceptance_claims"]
|
||||
if isinstance(claims, list):
|
||||
item["acceptance_claims"] = [
|
||||
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
|
||||
if isinstance(claim, dict) else claim
|
||||
for claim in claims
|
||||
]
|
||||
else:
|
||||
item["acceptance_claims"] = claims
|
||||
sanitized_requirements.append(item)
|
||||
sanitized["requirements"] = sanitized_requirements
|
||||
else:
|
||||
sanitized["requirements"] = requirements
|
||||
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessNextAction.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
@@ -228,18 +312,6 @@ def topology_request_schema(working_head: str) -> dict[str, Any]:
|
||||
return schema
|
||||
|
||||
|
||||
def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]:
|
||||
"""Bind a diagnostic conclusion to evidence generated for this head."""
|
||||
schema = GeometryConclusion.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
evidence = properties.get("evidence_refs")
|
||||
if isinstance(evidence, dict):
|
||||
evidence["items"] = {"enum": evidence_refs}
|
||||
return schema
|
||||
|
||||
|
||||
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
||||
schema = RollbackCheckpoint.model_json_schema()
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
"""One-pass requirements specification and server-owned contract artifacts."""
|
||||
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
RequirementsAuthorOutput,
|
||||
RequirementsClarification,
|
||||
RequirementsSpec,
|
||||
requirements_spec_schema,
|
||||
)
|
||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
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
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
"""Persist frozen documents and compile their checklist into a contract.
|
||||
|
||||
The model never names targets or internal objects during compilation. The
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
@@ -28,283 +34,257 @@ class RequirementsCommandHandler:
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
def register_evaluation_contract_oracle(
|
||||
self,
|
||||
task_id: str,
|
||||
required_claims: list[dict[str, Any]],
|
||||
*,
|
||||
validation_capability_gaps: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Retain release-evaluation metadata without changing production decisions."""
|
||||
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
|
||||
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
|
||||
self._evaluation_capability_gaps[task_id] = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in validation_capability_gaps or ()
|
||||
if isinstance(item, dict)
|
||||
for item in validation_capability_gaps or () if isinstance(item, dict)
|
||||
]
|
||||
|
||||
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
claims = self._evaluation_contract_oracles.get(task_id)
|
||||
if claims is None:
|
||||
return None
|
||||
return {
|
||||
return None if claims is None else {
|
||||
"evaluation_only": True,
|
||||
"required_claims": deepcopy(claims),
|
||||
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
||||
}
|
||||
|
||||
def spec_schema(self) -> dict[str, Any]:
|
||||
return requirements_spec_schema(self.registry.expected_one_of_schema())
|
||||
@staticmethod
|
||||
def document_schema() -> dict[str, Any]:
|
||||
return MarkdownDocument.model_json_schema()
|
||||
|
||||
def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting:
|
||||
def compiler_schema(self, task_id: str) -> dict[str, Any]:
|
||||
return compiled_requirements_schema(
|
||||
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
def submit_completion_target(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_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)
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
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 != TaskPhase.DRAFTING_REQUIREMENTS:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements are not expected in the current workflow phase."))
|
||||
value = output.root
|
||||
if isinstance(value, RequirementsClarification):
|
||||
return self._record_clarification(task_id, state, value, invocation_id=invocation_id)
|
||||
if not isinstance(value, RequirementsSpec):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements output is not a supported specification."))
|
||||
|
||||
field_errors: list[dict[str, str]] = []
|
||||
for requirement_index, requirement in enumerate(value.requirements):
|
||||
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||
try:
|
||||
errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||
except ValueError:
|
||||
errors = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||
field_errors.extend({
|
||||
"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{error['path']}",
|
||||
"message": error["message"],
|
||||
} for error in errors)
|
||||
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
|
||||
targets = self._checklist_items(task_id)
|
||||
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)
|
||||
if field_errors:
|
||||
return Rejected(WorkflowError(
|
||||
ErrorCode.REQUIREMENTS_SPEC_INVALID,
|
||||
"Requirements specification 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_spec", state.working_head, value.model_dump(mode="json")),
|
||||
)
|
||||
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")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
|
||||
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||
image_observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
warnings = [str(item) for item in image_observation.get("uncertainties") or () if str(item)]
|
||||
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
warnings = [
|
||||
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
|
||||
*compiler_warnings,
|
||||
]
|
||||
requirements: list[dict[str, Any]] = []
|
||||
claim_position = 1
|
||||
for position, item in enumerate(value.requirements, 1):
|
||||
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
|
||||
claims: list[dict[str, Any]] = []
|
||||
for claim in item.acceptance_claims:
|
||||
deterministic = self.registry.definition(claim.claim_kind).deterministic
|
||||
claims.append({
|
||||
"claim_id": f"claim_{claim_position:03d}",
|
||||
"claim_kind": claim.claim_kind,
|
||||
"expected": claim.expected,
|
||||
"verification_mode": "deterministic" if deterministic else "visual",
|
||||
})
|
||||
for claim in compiled.acceptance_claims:
|
||||
definition = self.registry.definition(claim.claim_kind)
|
||||
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
|
||||
claim_position += 1
|
||||
requirements.append({
|
||||
"requirement_id": f"req_{position:03d}",
|
||||
"source_ids": source_ids,
|
||||
"statement": item.statement,
|
||||
"assumptions": list(item.assumptions),
|
||||
"acceptance_claims": claims,
|
||||
})
|
||||
spec_payload = {
|
||||
"schema_version": "cad.requirements-spec.v1",
|
||||
"summary": value.summary,
|
||||
"assumptions": list(value.assumptions),
|
||||
"requirements": [item.model_dump(mode="json") for item in value.requirements],
|
||||
"image_observation_path": "documents/image-observation.json" if image_observation else "",
|
||||
}
|
||||
contract = {
|
||||
"schema_version": "cad.requirements-contract.v3",
|
||||
"task_id": task_id,
|
||||
"summary": value.summary,
|
||||
"assumptions": list(value.assumptions),
|
||||
"requirements": requirements,
|
||||
"verification_warnings": warnings,
|
||||
}
|
||||
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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["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_payload)
|
||||
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
||||
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(
|
||||
state,
|
||||
"requirements_approved",
|
||||
requirements_spec_path=spec_path,
|
||||
requirements_contract_path=contract_path,
|
||||
clarification_path="",
|
||||
)
|
||||
result = Accepted({"phase": next_state.phase.value, "contract_path": contract_path})
|
||||
if not self._commit(next_state, [{
|
||||
"event": "requirements_contract_frozen",
|
||||
"invocation_id": invocation_id,
|
||||
"contract_hash": contract["contract_hash"],
|
||||
"contract_path": contract_path,
|
||||
"requirement_count": len(requirements),
|
||||
"verification_warnings": warnings,
|
||||
}], invocation, result):
|
||||
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
|
||||
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
|
||||
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
self.ensure_rendered_contract_views(task_id, next_state)
|
||||
return result
|
||||
|
||||
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
|
||||
if not state.requirements_contract_path:
|
||||
return
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
raise RuntimeError("Committed requirements contract is unavailable")
|
||||
self.artifacts.write_requirements_contract(task_id, contract)
|
||||
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
|
||||
target = self._completion_target_markdown(contract)
|
||||
self.artifacts.write_text_once(task_id, "completion-target.md", target)
|
||||
|
||||
def write_completion_result(
|
||||
self,
|
||||
task_id: str,
|
||||
state: TaskState,
|
||||
*,
|
||||
claim_results: list[dict[str, Any]],
|
||||
review: dict[str, Any],
|
||||
) -> str:
|
||||
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
|
||||
visual = iter(review.get("visual_claims") or ())
|
||||
rows = ["# Completion Result", "", f"Status: {'completed with risks' if contract.get('verification_warnings') else 'verified'}", ""]
|
||||
rows = ["# Completion Result", "", "## Checklist", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
rows.append(f"## {requirement.get('statement')}")
|
||||
statuses: list[str] = []
|
||||
evidence: list[str] = []
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
if claim.get("verification_mode") == "visual":
|
||||
decision = next(visual, {})
|
||||
status = str(decision.get("status") or "unknown")
|
||||
evidence = str(decision.get("evidence") or "")
|
||||
else:
|
||||
result = by_id.get(str(claim.get("claim_id") or ""), {})
|
||||
status = str(result.get("status") or "unknown")
|
||||
evidence = json.dumps(result.get("evidence") or {}, ensure_ascii=False, sort_keys=True)
|
||||
rows.append(f"- [{'x' if status == 'pass' else ' '}] {claim.get('claim_kind')}: {status}")
|
||||
if evidence:
|
||||
rows.append(f" - Evidence: {evidence}")
|
||||
rows.append("")
|
||||
warnings = [str(item) for item in contract.get("verification_warnings") or () if str(item)]
|
||||
if warnings:
|
||||
rows.extend(["## Verification Warnings", "", *[f"- {item}" for item in warnings], ""])
|
||||
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
|
||||
statuses.append(str(result.get("status") or "unknown"))
|
||||
value = result.get("evidence")
|
||||
if value:
|
||||
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
|
||||
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
|
||||
rows.extend(f" - Evidence: {value}" for value in evidence)
|
||||
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
||||
|
||||
def _record_clarification(
|
||||
self,
|
||||
task_id: str,
|
||||
state: TaskState,
|
||||
clarification: RequirementsClarification,
|
||||
*,
|
||||
invocation_id: str,
|
||||
) -> Waiting | Rejected:
|
||||
evidence = self.artifacts.read_source_requirements(task_id)
|
||||
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
evidence += "\n" + json.dumps(observation, ensure_ascii=False)
|
||||
missing = [quote for quote in clarification.source_quotes if quote not in evidence]
|
||||
if missing:
|
||||
return Rejected(WorkflowError(
|
||||
ErrorCode.REQUIREMENTS_SPEC_INVALID,
|
||||
"Clarification quotes must be copied from the user request or image observation.",
|
||||
field_errors=tuple({"path": "/source_quotes", "message": f"Unknown quote: {quote}"} for quote in missing),
|
||||
))
|
||||
invocation = self.repository.begin_invocation(
|
||||
task_id,
|
||||
invocation_id,
|
||||
self._key(task_id, "requirements_clarification", state.working_head, clarification.model_dump(mode="json")),
|
||||
)
|
||||
payload = {"schema_version": "cad.requirements-clarification.v1", **clarification.model_dump(mode="json")}
|
||||
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
|
||||
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 != phase:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
|
||||
errors = validator(document.markdown)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload)
|
||||
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, clarification_path=path)
|
||||
result = Waiting(WorkflowError(
|
||||
ErrorCode.WAITING_FOR_USER,
|
||||
clarification.question,
|
||||
details={"questions": [clarification.question], "source_quotes": list(clarification.source_quotes)},
|
||||
))
|
||||
if not self._commit(next_state, [{
|
||||
"event": "requirements_waiting_for_user",
|
||||
"invocation_id": invocation_id,
|
||||
"review_path": path,
|
||||
"message": clarification.question,
|
||||
"questions": [clarification.question],
|
||||
"source_quotes": list(clarification.source_quotes),
|
||||
}], invocation, result):
|
||||
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
|
||||
next_state = transition(state, event, **kwargs)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written})
|
||||
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None:
|
||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||
if invocation is None or invocation.status != "finished" or invocation.result is None:
|
||||
return None
|
||||
return self._restore(invocation.result)
|
||||
def _checklist_items(self, task_id: str) -> list[str]:
|
||||
path = self.artifacts.task_dir(task_id) / "completion-target.md"
|
||||
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
|
||||
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
for requirement_index, requirement in enumerate(output.requirements):
|
||||
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||
try:
|
||||
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||
except ValueError:
|
||||
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
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 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."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
|
||||
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
|
||||
warnings.append(
|
||||
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
|
||||
)
|
||||
continue
|
||||
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
|
||||
normalized_claims.append(claim)
|
||||
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _restore(payload: dict[str, Any]) -> Accepted | Waiting:
|
||||
if payload.get("result_type") == "waiting":
|
||||
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
||||
return Waiting(WorkflowError(
|
||||
ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)),
|
||||
str(error.get("message") or "Requirements need a user decision."),
|
||||
tuple(error.get("field_errors") or ()),
|
||||
bool(error.get("retryable")),
|
||||
dict(error.get("details") or {}),
|
||||
))
|
||||
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":
|
||||
return False
|
||||
if RequirementsCommandHandler._target_describes_finished_envelope(target):
|
||||
return False
|
||||
return any(
|
||||
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
|
||||
for item in claims
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _target_describes_finished_envelope(target: str) -> bool:
|
||||
lowered = target.lower()
|
||||
return any(token in lowered for token in (
|
||||
"overall",
|
||||
"total",
|
||||
"finished part",
|
||||
"entire part",
|
||||
"whole part",
|
||||
"bounding box",
|
||||
"envelope",
|
||||
"总",
|
||||
"整体",
|
||||
"成品",
|
||||
"全高",
|
||||
"包围盒",
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
|
||||
value = bbox_claim.expected.get("value")
|
||||
if not isinstance(value, (int, float)):
|
||||
return
|
||||
for item in claims:
|
||||
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
|
||||
continue
|
||||
expected = getattr(item, "expected", None)
|
||||
if not isinstance(expected, dict) or "axial_span_mm" in expected:
|
||||
continue
|
||||
expected["axial_span_mm"] = value
|
||||
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
|
||||
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
|
||||
# Markdown is a human-facing semantic artifact. Its content is frozen
|
||||
# verbatim and is not executable, so headings are guidance for the
|
||||
# author rather than a server-enforced protocol.
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
|
||||
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
errors: list[dict[str, str]] = []
|
||||
if not values:
|
||||
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
|
||||
if len(values) != len(set(values)):
|
||||
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
|
||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
|
||||
|
||||
@staticmethod
|
||||
def _restore(payload: dict[str, Any]) -> Accepted:
|
||||
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
|
||||
|
||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
|
||||
payload = {"result_type": "waiting", "error": result.error.payload()} if isinstance(result, Waiting) else {"result_type": "accepted", "payload": result.payload}
|
||||
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result=payload)
|
||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
|
||||
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
|
||||
|
||||
@staticmethod
|
||||
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _requirements_markdown(contract: dict[str, Any]) -> str:
|
||||
rows = ["# Requirements", "", str(contract.get("summary") or ""), ""]
|
||||
assumptions = [str(item) for item in contract.get("assumptions") or () if str(item)]
|
||||
if assumptions:
|
||||
rows.extend(["## Assumptions", "", *[f"- {item}" for item in assumptions], ""])
|
||||
rows.extend(["## Requirements", ""])
|
||||
for item in contract.get("requirements") or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rows.append(f"- {item.get('statement')}")
|
||||
rows.extend(f" - Assumption: {value}" for value in item.get("assumptions") or ())
|
||||
return "\n".join(rows).rstrip() + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _completion_target_markdown(contract: dict[str, Any]) -> str:
|
||||
rows = ["# Completion Target", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
rows.append(f"## {requirement.get('statement')}")
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if isinstance(claim, dict):
|
||||
rows.append(f"- [ ] {claim.get('claim_kind')}: {json.dumps(claim.get('expected') or {}, ensure_ascii=False, sort_keys=True)}")
|
||||
rows.append("")
|
||||
return "\n".join(rows).rstrip() + "\n"
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _stale() -> WorkflowError:
|
||||
@@ -312,9 +292,5 @@ class RequirementsCommandHandler:
|
||||
|
||||
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||
self.repository.compare_and_swap(waiting, events=[{
|
||||
"event": "waiting_retry",
|
||||
"code": ErrorCode.STORAGE_FAILURE.value,
|
||||
"message": message[:1000],
|
||||
}])
|
||||
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
|
||||
|
||||
@@ -17,11 +17,12 @@ from pydantic import BaseModel
|
||||
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, NextAction,
|
||||
RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview,
|
||||
CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction,
|
||||
RollbackCheckpoint, StatelessCandidateReview,
|
||||
StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint,
|
||||
StatelessTopologyRequest, TopologyRequest,
|
||||
canonical_json_object, canonical_validate, canonical_validate_schema,
|
||||
sanitize_compiled_requirements_arguments,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema, stateless_rollback_checkpoint_schema,
|
||||
raw_arguments_hash, validate_one_tool_call,
|
||||
@@ -230,16 +231,6 @@ class WorkflowCoordinator:
|
||||
if state is None:
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."}
|
||||
return
|
||||
if state.requirements_contract_path and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
try:
|
||||
self.requirements.ensure_rendered_contract_views(task_id, state)
|
||||
except Exception as error:
|
||||
yield self._service_failure(
|
||||
task_id,
|
||||
state,
|
||||
WorkflowError(ErrorCode.STORAGE_FAILURE, str(error)[:1000], retryable=True),
|
||||
)
|
||||
return
|
||||
if state.phase == TaskPhase.COMPLETED:
|
||||
yield "task_terminal", self._projected_terminal(task_id, state)
|
||||
return
|
||||
@@ -264,7 +255,7 @@ class WorkflowCoordinator:
|
||||
yield self._service_failure(task_id, state, recovered.error)
|
||||
return
|
||||
continue
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
||||
image_paths = self.artifacts.source_image_paths(task_id)
|
||||
if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None:
|
||||
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
|
||||
@@ -299,8 +290,8 @@ class WorkflowCoordinator:
|
||||
}])
|
||||
yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"}
|
||||
continue
|
||||
spec_schema = self.requirements.spec_schema()
|
||||
tools = [self._tool("submit_requirements_spec", spec_schema)]
|
||||
document_schema = self.requirements.document_schema()
|
||||
tools = [self._tool("write_requirements_document", document_schema)]
|
||||
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
|
||||
if terminal:
|
||||
yield terminal
|
||||
@@ -321,8 +312,8 @@ class WorkflowCoordinator:
|
||||
return
|
||||
continue
|
||||
name, raw, usage = result
|
||||
validation = canonical_validate(raw, RequirementsAuthorOutput)
|
||||
dynamic_error = canonical_validate_schema(raw, spec_schema) if not isinstance(validation, WorkflowError) else None
|
||||
validation = canonical_validate(raw, MarkdownDocument)
|
||||
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
|
||||
if dynamic_error is not None:
|
||||
validation = dynamic_error
|
||||
if isinstance(validation, WorkflowError):
|
||||
@@ -333,7 +324,7 @@ class WorkflowCoordinator:
|
||||
return
|
||||
continue
|
||||
invocation_id = self._invocation_id(task_id)
|
||||
command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id)
|
||||
command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id)
|
||||
if isinstance(command, Rejected):
|
||||
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback)
|
||||
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
@@ -341,7 +332,106 @@ class WorkflowCoordinator:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "success", usage)
|
||||
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
event_payload["markdown"] = validation.markdown
|
||||
yield "requirements_document_ready", event_payload
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}:
|
||||
document_schema = self.requirements.document_schema()
|
||||
tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan"
|
||||
tools = [self._tool(tool_name, document_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, "author_turn", result, format_errors, feedback)
|
||||
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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, MarkdownDocument)
|
||||
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
|
||||
if dynamic_error is not None:
|
||||
validation = dynamic_error
|
||||
if isinstance(validation, WorkflowError):
|
||||
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name)
|
||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_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=tool_name)
|
||||
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
event_payload["markdown"] = validation.markdown
|
||||
yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase == TaskPhase.COMPILING_REQUIREMENTS:
|
||||
compiler_schema = self.requirements.compiler_schema(task_id)
|
||||
tools = [self._tool("compile_requirements_spec", compiler_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, "author_turn", result, format_errors, feedback)
|
||||
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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
|
||||
sanitized_raw = sanitize_compiled_requirements_arguments(raw)
|
||||
if isinstance(sanitized_raw, WorkflowError):
|
||||
validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw
|
||||
else:
|
||||
validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec)
|
||||
dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None
|
||||
if dynamic_error is not None:
|
||||
validation = dynamic_error
|
||||
if isinstance(validation, WorkflowError):
|
||||
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec")
|
||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
command = self.requirements.submit_compiled_spec(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="compile_requirements_spec")
|
||||
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase == TaskPhase.AWAITING_ACTION:
|
||||
@@ -472,11 +562,7 @@ class WorkflowCoordinator:
|
||||
continue
|
||||
name, raw, usage = result
|
||||
if name == "record_geometry_conclusion":
|
||||
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
|
||||
validation = canonical_validate(raw, GeometryConclusion)
|
||||
dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None
|
||||
if dynamic_error is not None:
|
||||
validation = dynamic_error
|
||||
validation = canonical_validate(raw, StatelessGeometryConclusion)
|
||||
if isinstance(validation, WorkflowError):
|
||||
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
||||
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
@@ -484,6 +570,13 @@ class WorkflowCoordinator:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
validation = GeometryConclusion(
|
||||
working_head=state.working_head,
|
||||
evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)),
|
||||
root_cause=validation.root_cause,
|
||||
decision=validation.decision,
|
||||
corrective_intent=validation.corrective_intent,
|
||||
)
|
||||
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
|
||||
if isinstance(command, Rejected):
|
||||
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
||||
@@ -675,6 +768,11 @@ class WorkflowCoordinator:
|
||||
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
state = self.repository.get_state(task_id)
|
||||
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"}
|
||||
yield terminal
|
||||
return
|
||||
if state:
|
||||
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
|
||||
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}])
|
||||
@@ -683,6 +781,11 @@ class WorkflowCoordinator:
|
||||
yield self._storage_failure(task_id, str(error))
|
||||
except Exception as error:
|
||||
state = self.repository.get_state(task_id)
|
||||
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"}
|
||||
yield terminal
|
||||
return
|
||||
if state 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": "failed_internal", "message": str(error)[:1000]}])
|
||||
@@ -711,6 +814,15 @@ class WorkflowCoordinator:
|
||||
return None
|
||||
details = budget.payload()
|
||||
details["next_actor"] = actor
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
state,
|
||||
ErrorCode.CALL_BUDGET_EXHAUSTED,
|
||||
"Configured model-call budget is exhausted; publishing the last executable checkpoint.",
|
||||
budget=details,
|
||||
)
|
||||
if terminal:
|
||||
return terminal
|
||||
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "call_budget_exhausted",
|
||||
@@ -728,6 +840,49 @@ class WorkflowCoordinator:
|
||||
"userActionRequired": False,
|
||||
}
|
||||
|
||||
def _best_effort_terminal(
|
||||
self,
|
||||
task_id: str,
|
||||
state: TaskState,
|
||||
reason: ErrorCode,
|
||||
message: str,
|
||||
*,
|
||||
budget: dict[str, Any] | None = None,
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
return None
|
||||
claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision))
|
||||
visual_claims = [
|
||||
{"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."}
|
||||
for item in claim_results
|
||||
if not item.get("deterministic")
|
||||
]
|
||||
try:
|
||||
self.requirements.write_completion_result(
|
||||
task_id,
|
||||
state,
|
||||
claim_results=claim_results,
|
||||
review={"visual_claims": visual_claims},
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id))
|
||||
if isinstance(completed, Rejected):
|
||||
return None
|
||||
return "task_terminal", {
|
||||
"taskId": task_id,
|
||||
"lifecycle": "completed",
|
||||
"revisionId": state.active_revision,
|
||||
"code": ErrorCode.BEST_EFFORT_COMPLETED.value,
|
||||
"message": message,
|
||||
"issues": completed.payload.get("issues") or [],
|
||||
"verificationStatus": "completed_with_risks",
|
||||
"verificationWarnings": completed.payload.get("issues") or [],
|
||||
"completionResultPath": "completion-result.md",
|
||||
"budget": budget or {},
|
||||
"userActionRequired": False,
|
||||
}
|
||||
|
||||
def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]:
|
||||
"""Park a nonterminal task when a durable artifact operation fails."""
|
||||
state = self.repository.get_state(task_id)
|
||||
@@ -1045,6 +1200,8 @@ class WorkflowCoordinator:
|
||||
return [self._tool("inspect_topology", StatelessTopologyRequest)]
|
||||
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
|
||||
description = "Submit exactly one CDSL feature for the pending action."
|
||||
if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}:
|
||||
description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane."
|
||||
if action.atomic_id.startswith("hole_"):
|
||||
description += (
|
||||
" Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. "
|
||||
@@ -1057,7 +1214,7 @@ class WorkflowCoordinator:
|
||||
"feature.selector_tokens is required author input: copy one of the opaque tokens from "
|
||||
"inspect_topology; the server resolves it to the host after validation."
|
||||
)
|
||||
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references))}}
|
||||
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}}
|
||||
return [fragment]
|
||||
|
||||
def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
@@ -1076,18 +1233,41 @@ class WorkflowCoordinator:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return []
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
||||
content = {
|
||||
"protocol": "cad.v3.spec.v1",
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"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),
|
||||
"instruction": (
|
||||
"Return one complete bounded requirements specification. Use deterministic claims only when the supplied registry can execute them; otherwise use a scoped visual claim. "
|
||||
"Unspecified design choices are assumptions and must not block generation. Return clarification only when two quoted source statements cannot both be followed and the user must choose; ask exactly one question. "
|
||||
"Never return source, task, draft, requirement, claim, revision, candidate, action, head, or evidence identifiers. Do not solve semantic conflicts by changing a user value."
|
||||
"Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. "
|
||||
"Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers."
|
||||
),
|
||||
}
|
||||
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"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",
|
||||
"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.",
|
||||
}
|
||||
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"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.",
|
||||
}
|
||||
else:
|
||||
contract = self._requirements_contract(task_id, state) or {}
|
||||
compact = [{
|
||||
@@ -1117,7 +1297,7 @@ 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", "phase": state.phase.value, "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}
|
||||
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}
|
||||
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:]]
|
||||
|
||||
@@ -1133,6 +1313,15 @@ class WorkflowCoordinator:
|
||||
clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text})
|
||||
return clarifications
|
||||
|
||||
def _read_markdown(self, task_id: str, relative_path: str) -> str:
|
||||
if not relative_path:
|
||||
return ""
|
||||
try:
|
||||
path = self.artifacts.artifact_path(task_id, relative_path)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
||||
"""Expose the persisted requirement question when a task is parked.
|
||||
|
||||
@@ -1232,6 +1421,18 @@ class WorkflowCoordinator:
|
||||
action = state.pending_action
|
||||
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
|
||||
|
||||
@staticmethod
|
||||
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
|
||||
protocol = {
|
||||
"system": "world_mm_right_handed",
|
||||
"sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.",
|
||||
"vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.",
|
||||
"hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.",
|
||||
}
|
||||
if not state.active_revision:
|
||||
protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y."
|
||||
return protocol
|
||||
|
||||
@staticmethod
|
||||
def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not isinstance(payload, dict):
|
||||
@@ -1265,7 +1466,7 @@ class WorkflowCoordinator:
|
||||
topology = self.artifacts.read_topology(task_id, state.active_revision)
|
||||
tokens = self.runtime.selector_tokens(topology)
|
||||
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
|
||||
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references))}
|
||||
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}
|
||||
|
||||
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
constraints: list[dict[str, Any]] = []
|
||||
@@ -1334,10 +1535,25 @@ class WorkflowCoordinator:
|
||||
|
||||
def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
|
||||
"""Bound no-side-effect model rejections; park real service failures."""
|
||||
if error.code == ErrorCode.NO_PROGRESS_LIMIT:
|
||||
current = self.repository.get_state(task_id) or state
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
current,
|
||||
ErrorCode.NO_PROGRESS_LIMIT,
|
||||
"Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.",
|
||||
)
|
||||
if terminal:
|
||||
return terminal
|
||||
feedback[:] = [self._feedback(error)]
|
||||
return None
|
||||
if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID:
|
||||
# Registry integrity is a deployment defect. Retrying a model with
|
||||
# the same broken contract cannot repair it and must never consume
|
||||
# the author-format budget.
|
||||
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
|
||||
if terminal:
|
||||
return terminal
|
||||
failed = transition(state, "failed", error=error.code)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "runtime_contract_invalid",
|
||||
@@ -1350,6 +1566,27 @@ class WorkflowCoordinator:
|
||||
"code": error.code.value,
|
||||
"message": error.message,
|
||||
}
|
||||
if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID:
|
||||
# A frozen verifier contract is service-owned input at this stage.
|
||||
# CAD retries cannot repair it, so preserve the terminal diagnosis
|
||||
# instead of parking the task or blaming the author fragment.
|
||||
current = self.repository.get_state(task_id)
|
||||
if current is not None and current.phase != TaskPhase.FAILED:
|
||||
failed = transition(current, "failed", error=error.code)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "requirements_contract_execution_failed",
|
||||
"tool": name,
|
||||
"message": error.message,
|
||||
}])
|
||||
return "task_terminal", {
|
||||
"taskId": task_id,
|
||||
"lifecycle": "failed",
|
||||
"code": error.code.value,
|
||||
"message": error.message,
|
||||
"blockerType": "requirements_contract_invalid",
|
||||
"userActionRequired": False,
|
||||
"issues": [str(error.details.get("diagnostic") or error.message)],
|
||||
}
|
||||
if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED:
|
||||
# A schema-valid fragment can still be impossible on the current
|
||||
# geometry. This is not an author-format failure: preserve the
|
||||
@@ -1368,8 +1605,8 @@ class WorkflowCoordinator:
|
||||
if event.get("failure_class_fingerprint") == failure_class_fingerprint
|
||||
]
|
||||
if len(prior) >= 2:
|
||||
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "no_progress_limit",
|
||||
"tool": name,
|
||||
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
@@ -1379,15 +1616,16 @@ class WorkflowCoordinator:
|
||||
"normalized_error_code": normalized_code,
|
||||
"failure_class_fingerprint": failure_class_fingerprint,
|
||||
}])
|
||||
return "task_terminal", {
|
||||
"taskId": task_id,
|
||||
"lifecycle": "failed",
|
||||
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The same operation failure class made no progress after three attempts; the last checkpoint was preserved.",
|
||||
"blockerType": "no_progress_limit",
|
||||
"userActionRequired": False,
|
||||
"revisionId": state.active_revision,
|
||||
}
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
next_state,
|
||||
ErrorCode.NO_PROGRESS_LIMIT,
|
||||
"The same operation failure made no progress; publishing the last executable checkpoint.",
|
||||
)
|
||||
if terminal:
|
||||
return terminal
|
||||
feedback[:] = [self._feedback(error)]
|
||||
return None
|
||||
next_state = transition(
|
||||
state,
|
||||
"runtime_precondition_rejected",
|
||||
@@ -1419,6 +1657,9 @@ class WorkflowCoordinator:
|
||||
return None
|
||||
# A precondition result outside fragment submission is an invalid
|
||||
# workflow implementation state, not a provider/service outage.
|
||||
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
|
||||
if terminal:
|
||||
return terminal
|
||||
failed = transition(state, "failed", error=error.code)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "runtime_precondition_rejected",
|
||||
@@ -1432,6 +1673,16 @@ class WorkflowCoordinator:
|
||||
"code": error.code.value,
|
||||
"message": error.message,
|
||||
}
|
||||
if error.code in {
|
||||
ErrorCode.CANDIDATE_BUILD_FAILED,
|
||||
ErrorCode.CLAIM_VERIFICATION_FAILED,
|
||||
ErrorCode.CANDIDATE_REVIEW_REJECTED,
|
||||
}:
|
||||
# The handler already preserved the last checkpoint and recorded
|
||||
# the field/runtime diagnostic. Continue with that evidence; a
|
||||
# planning miss is not an infrastructure terminal condition.
|
||||
feedback[:] = [self._feedback(error)]
|
||||
return None
|
||||
model_rejection_codes = {
|
||||
ErrorCode.AUTHOR_FORMAT_INVALID,
|
||||
ErrorCode.AUTHOR_DECISION_REJECTED,
|
||||
@@ -1446,6 +1697,14 @@ class WorkflowCoordinator:
|
||||
feedback[:] = [self._feedback(error)]
|
||||
if counters[name] < self.config.format_error_limit:
|
||||
return None
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
state,
|
||||
ErrorCode.FAILED_AUTHOR_FORMAT,
|
||||
f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.",
|
||||
)
|
||||
if terminal:
|
||||
return terminal
|
||||
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)}])
|
||||
return "task_terminal", {
|
||||
@@ -1464,6 +1723,8 @@ class WorkflowCoordinator:
|
||||
error: WorkflowError,
|
||||
counters: dict[str, int],
|
||||
feedback: list[dict[str, Any]],
|
||||
*,
|
||||
tool: str = "compile_requirements_spec",
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
key = "requirements_spec"
|
||||
counters[key] = counters.get(key, 0) + 1
|
||||
@@ -1482,7 +1743,7 @@ class WorkflowCoordinator:
|
||||
"lifecycle": "failed",
|
||||
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
|
||||
"message": "Requirements specification remained unreadable after one field-level correction.",
|
||||
"tool": "submit_requirements_spec",
|
||||
"tool": tool,
|
||||
"field_errors": list(error.field_errors),
|
||||
"userActionRequired": False,
|
||||
}
|
||||
@@ -1494,10 +1755,12 @@ class WorkflowCoordinator:
|
||||
error: WorkflowError,
|
||||
counters: dict[str, int],
|
||||
feedback: list[dict[str, Any]],
|
||||
*,
|
||||
tool: str = "compile_requirements_spec",
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
|
||||
return self._service_failure(task_id, state, error)
|
||||
return self._requirements_format_failure(task_id, state, error, counters, feedback)
|
||||
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
|
||||
|
||||
@staticmethod
|
||||
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user