agent-lifecycle-kit 1.50.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. agent_lifecycle/__init__.py +5 -0
  2. agent_lifecycle/__main__.py +5 -0
  3. agent_lifecycle/_version.py +3 -0
  4. agent_lifecycle/adapter_sessions/__init__.py +41 -0
  5. agent_lifecycle/adapter_sessions/contracts.py +133 -0
  6. agent_lifecycle/adapter_sessions/env.py +44 -0
  7. agent_lifecycle/adapter_sessions/launcher.py +101 -0
  8. agent_lifecycle/adapter_sessions/process.py +39 -0
  9. agent_lifecycle/adapter_sessions/redaction.py +30 -0
  10. agent_lifecycle/adapter_sessions/session_store.py +64 -0
  11. agent_lifecycle/adapter_sessions/task_intake.py +450 -0
  12. agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
  13. agent_lifecycle/audit/__init__.py +67 -0
  14. agent_lifecycle/audit/bug_forensics.py +94 -0
  15. agent_lifecycle/audit/implementation.py +516 -0
  16. agent_lifecycle/audit/ownership.py +119 -0
  17. agent_lifecycle/audit/proof_integrity.py +814 -0
  18. agent_lifecycle/audit/review_verdict.py +116 -0
  19. agent_lifecycle/changesets/__init__.py +5 -0
  20. agent_lifecycle/changesets/git.py +35 -0
  21. agent_lifecycle/cli/__init__.py +5 -0
  22. agent_lifecycle/cli/adapter.py +360 -0
  23. agent_lifecycle/cli/dispatch.py +1220 -0
  24. agent_lifecycle/cli/followup.py +89 -0
  25. agent_lifecycle/cli/main.py +41 -0
  26. agent_lifecycle/cli/metrics_parser.py +49 -0
  27. agent_lifecycle/cli/parsers.py +618 -0
  28. agent_lifecycle/cli/policy.py +101 -0
  29. agent_lifecycle/cli/progress_hooks.py +117 -0
  30. agent_lifecycle/cli/worktree.py +74 -0
  31. agent_lifecycle/compiler/__init__.py +17 -0
  32. agent_lifecycle/compiler/small_model_packets.py +322 -0
  33. agent_lifecycle/compiler/task_packets.py +229 -0
  34. agent_lifecycle/context/__init__.py +20 -0
  35. agent_lifecycle/context/episode_retrieval.py +30 -0
  36. agent_lifecycle/context/external_memory.py +71 -0
  37. agent_lifecycle/context/profiles.py +104 -0
  38. agent_lifecycle/context/rendering.py +226 -0
  39. agent_lifecycle/contracts/__init__.py +25 -0
  40. agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
  41. agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
  42. agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
  43. agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
  44. agent_lifecycle/contracts/audit_schemas.py +133 -0
  45. agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
  46. agent_lifecycle/contracts/canonical.py +55 -0
  47. agent_lifecycle/contracts/compatibility.py +242 -0
  48. agent_lifecycle/contracts/context_model_schemas.py +265 -0
  49. agent_lifecycle/contracts/core_schemas.py +252 -0
  50. agent_lifecycle/contracts/cross_check_schemas.py +121 -0
  51. agent_lifecycle/contracts/errors.py +26 -0
  52. agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
  53. agent_lifecycle/contracts/host_capability_schemas.py +64 -0
  54. agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
  55. agent_lifecycle/contracts/metric_schemas.py +315 -0
  56. agent_lifecycle/contracts/paths.py +31 -0
  57. agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
  58. agent_lifecycle/contracts/policy_schemas.py +350 -0
  59. agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
  60. agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
  61. agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
  62. agent_lifecycle/contracts/release_contract_schemas.py +476 -0
  63. agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
  64. agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
  65. agent_lifecycle/contracts/review_quality_schemas.py +109 -0
  66. agent_lifecycle/contracts/runner_schemas.py +91 -0
  67. agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
  68. agent_lifecycle/contracts/sandbox_schemas.py +159 -0
  69. agent_lifecycle/contracts/schema_builders.py +23 -0
  70. agent_lifecycle/contracts/schemas.py +93 -0
  71. agent_lifecycle/contracts/status_goal_schemas.py +469 -0
  72. agent_lifecycle/contracts/task_template_schemas.py +96 -0
  73. agent_lifecycle/contracts/usage_export_schemas.py +68 -0
  74. agent_lifecycle/diagnostics/__init__.py +17 -0
  75. agent_lifecycle/diagnostics/bundles.py +124 -0
  76. agent_lifecycle/diagnostics/readiness.py +733 -0
  77. agent_lifecycle/evidence_index/__init__.py +39 -0
  78. agent_lifecycle/evidence_index/core.py +272 -0
  79. agent_lifecycle/evidence_index/episode_index.py +329 -0
  80. agent_lifecycle/evidence_index/external_context.py +276 -0
  81. agent_lifecycle/followup/__init__.py +23 -0
  82. agent_lifecycle/followup/records.py +404 -0
  83. agent_lifecycle/freeze/__init__.py +5 -0
  84. agent_lifecycle/freeze/locks.py +23 -0
  85. agent_lifecycle/goal/__init__.py +15 -0
  86. agent_lifecycle/goal/records.py +244 -0
  87. agent_lifecycle/goal/view.py +109 -0
  88. agent_lifecycle/host_protocol/__init__.py +65 -0
  89. agent_lifecycle/host_protocol/acp_capability.py +195 -0
  90. agent_lifecycle/host_protocol/capabilities.py +268 -0
  91. agent_lifecycle/host_protocol/contracts.py +214 -0
  92. agent_lifecycle/host_protocol/event_capture.py +390 -0
  93. agent_lifecycle/host_protocol/events.py +196 -0
  94. agent_lifecycle/host_protocol/inspection.py +194 -0
  95. agent_lifecycle/host_protocol/inspection_common.py +310 -0
  96. agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
  97. agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
  98. agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
  99. agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
  100. agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
  101. agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
  102. agent_lifecycle/host_protocol/receipts.py +58 -0
  103. agent_lifecycle/host_protocol/scaffold.py +329 -0
  104. agent_lifecycle/host_protocol/validation.py +229 -0
  105. agent_lifecycle/imports/__init__.py +76 -0
  106. agent_lifecycle/imports/agentskills_profile.py +54 -0
  107. agent_lifecycle/imports/bmad_profile.py +49 -0
  108. agent_lifecycle/imports/constitution_adr.py +91 -0
  109. agent_lifecycle/imports/dialect_profiles.py +121 -0
  110. agent_lifecycle/imports/external_agent.py +28 -0
  111. agent_lifecycle/imports/external_dialects.py +486 -0
  112. agent_lifecycle/imports/external_workflow.py +28 -0
  113. agent_lifecycle/imports/multi_markdown.py +111 -0
  114. agent_lifecycle/imports/openspec_profile.py +49 -0
  115. agent_lifecycle/imports/planning.py +536 -0
  116. agent_lifecycle/imports/spec_kit_profile.py +49 -0
  117. agent_lifecycle/imports/spec_kitty_profile.py +49 -0
  118. agent_lifecycle/metrics/__init__.py +60 -0
  119. agent_lifecycle/metrics/cost_collection.py +315 -0
  120. agent_lifecycle/metrics/costs.py +203 -0
  121. agent_lifecycle/metrics/outcome_index.py +330 -0
  122. agent_lifecycle/metrics/phase_resources.py +208 -0
  123. agent_lifecycle/metrics/recommendations.py +507 -0
  124. agent_lifecycle/metrics/regression_signals.py +64 -0
  125. agent_lifecycle/metrics/usage_export.py +390 -0
  126. agent_lifecycle/model_routing/__init__.py +19 -0
  127. agent_lifecycle/model_routing/profiles.py +323 -0
  128. agent_lifecycle/model_routing/receipts.py +140 -0
  129. agent_lifecycle/model_routing/resolver.py +566 -0
  130. agent_lifecycle/neutrality/__init__.py +12 -0
  131. agent_lifecycle/neutrality/__main__.py +6 -0
  132. agent_lifecycle/neutrality/authority.py +265 -0
  133. agent_lifecycle/neutrality/canonical.py +28 -0
  134. agent_lifecycle/neutrality/cli.py +294 -0
  135. agent_lifecycle/neutrality/ed25519.py +124 -0
  136. agent_lifecycle/neutrality/errors.py +89 -0
  137. agent_lifecycle/neutrality/gate.py +269 -0
  138. agent_lifecycle/neutrality/paths.py +92 -0
  139. agent_lifecycle/neutrality/policy.py +76 -0
  140. agent_lifecycle/neutrality/receipt.py +135 -0
  141. agent_lifecycle/neutrality/scanner.py +310 -0
  142. agent_lifecycle/neutrality/test_runner.py +116 -0
  143. agent_lifecycle/planning/__init__.py +47 -0
  144. agent_lifecycle/planning/acceptance_markdown.py +159 -0
  145. agent_lifecycle/planning/completeness.py +362 -0
  146. agent_lifecycle/planning/continuity.py +255 -0
  147. agent_lifecycle/planning/templates.py +294 -0
  148. agent_lifecycle/planning/tiering.py +87 -0
  149. agent_lifecycle/planning/validation.py +72 -0
  150. agent_lifecycle/policy/__init__.py +37 -0
  151. agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
  152. agent_lifecycle/policy/apply.py +44 -0
  153. agent_lifecycle/policy/proposals.py +189 -0
  154. agent_lifecycle/policy/quality_floor.py +144 -0
  155. agent_lifecycle/policy/runtime_receipts.py +160 -0
  156. agent_lifecycle/py.typed +1 -0
  157. agent_lifecycle/quality/__init__.py +89 -0
  158. agent_lifecycle/quality/bug_forensics.py +727 -0
  159. agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
  160. agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
  161. agent_lifecycle/quality/cross_check.py +437 -0
  162. agent_lifecycle/quality/failure_classification.py +318 -0
  163. agent_lifecycle/quality/packs.py +265 -0
  164. agent_lifecycle/reporting/__init__.py +37 -0
  165. agent_lifecycle/reporting/change_summary.py +156 -0
  166. agent_lifecycle/reporting/event_feed.py +101 -0
  167. agent_lifecycle/reporting/progress_bridge.py +153 -0
  168. agent_lifecycle/reporting/progress_hooks.py +161 -0
  169. agent_lifecycle/reporting/progress_terminal.py +95 -0
  170. agent_lifecycle/reporting/progress_view.py +284 -0
  171. agent_lifecycle/reporting/status_view.py +125 -0
  172. agent_lifecycle/reporting/usage_export.py +59 -0
  173. agent_lifecycle/review/__init__.py +5 -0
  174. agent_lifecycle/review/validation.py +29 -0
  175. agent_lifecycle/review_mesh/__init__.py +83 -0
  176. agent_lifecycle/review_mesh/assignments.py +160 -0
  177. agent_lifecycle/review_mesh/contracts.py +685 -0
  178. agent_lifecycle/review_mesh/operator_templates.py +320 -0
  179. agent_lifecycle/review_mesh/quorum.py +38 -0
  180. agent_lifecycle/review_mesh/recommendation.py +487 -0
  181. agent_lifecycle/review_mesh/results.py +98 -0
  182. agent_lifecycle/review_mesh/synthesis.py +102 -0
  183. agent_lifecycle/runner/__init__.py +47 -0
  184. agent_lifecycle/runner/attempt_snapshots.py +244 -0
  185. agent_lifecycle/runner/core.py +653 -0
  186. agent_lifecycle/runner/sandbox_receipts.py +628 -0
  187. agent_lifecycle/specification/__init__.py +23 -0
  188. agent_lifecycle/specification/completion_check.py +135 -0
  189. agent_lifecycle/specification/completion_gate.py +501 -0
  190. agent_lifecycle/specification/completion_signal.py +75 -0
  191. agent_lifecycle/specification/validation.py +48 -0
  192. agent_lifecycle/workflow/__init__.py +79 -0
  193. agent_lifecycle/workflow/artifacts.py +74 -0
  194. agent_lifecycle/workflow/budget_decisions.py +370 -0
  195. agent_lifecycle/workflow/budget_policy.py +140 -0
  196. agent_lifecycle/workflow/budget_receipts.py +70 -0
  197. agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
  198. agent_lifecycle/workflow/controller.py +45 -0
  199. agent_lifecycle/workflow/events.py +50 -0
  200. agent_lifecycle/workflow/final_proof_integrity.py +74 -0
  201. agent_lifecycle/workflow/finalization.py +422 -0
  202. agent_lifecycle/workflow/gates.py +185 -0
  203. agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
  204. agent_lifecycle/workflow/leases.py +220 -0
  205. agent_lifecycle/workflow/lineage.py +108 -0
  206. agent_lifecycle/workflow/managed_runner.py +167 -0
  207. agent_lifecycle/workflow/model_usage.py +106 -0
  208. agent_lifecycle/workflow/next_action.py +86 -0
  209. agent_lifecycle/workflow/operation_kernel.py +98 -0
  210. agent_lifecycle/workflow/plan_adoption.py +439 -0
  211. agent_lifecycle/workflow/query.py +52 -0
  212. agent_lifecycle/workflow/review_mesh_gate.py +93 -0
  213. agent_lifecycle/workflow/reviews.py +168 -0
  214. agent_lifecycle/workflow/run_transitions.py +163 -0
  215. agent_lifecycle/workflow/sandbox_policy.py +214 -0
  216. agent_lifecycle/workflow/selectors.py +57 -0
  217. agent_lifecycle/workflow/state.py +130 -0
  218. agent_lifecycle/workflow/task_transitions.py +421 -0
  219. agent_lifecycle/worktree/__init__.py +17 -0
  220. agent_lifecycle/worktree/isolation.py +407 -0
  221. agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
  222. agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
  223. agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
  224. agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
  225. agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
  226. agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
  227. agent_lifecycle_kit-1.50.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,75 @@
1
+ """Completion signal validation for terminal lifecycle decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest
8
+
9
+
10
+ def validate_completion_signal(signal: dict[str, Any], *, state: dict[str, Any]) -> dict[str, Any]:
11
+ """Validate the final completion signal or explicit waiver."""
12
+
13
+ if not isinstance(signal, dict):
14
+ raise LifecycleError("completion-signal-required", "final audit requires completionSignal")
15
+ if signal.get("schemaVersion") != "agent-completion-signal.v1":
16
+ raise LifecycleError("invalid-completion-signal", "completionSignal schemaVersion is unsupported")
17
+ status = signal.get("status")
18
+ if status not in {"PASS", "WAIVED", "FAIL"}:
19
+ raise LifecycleError("invalid-completion-signal", "completionSignal status is unsupported")
20
+ _require_lineage(signal, state)
21
+ evidence_ids = _string_list(signal.get("evidenceIds"), label="completionSignal.evidenceIds")
22
+ if not evidence_ids:
23
+ raise LifecycleError("invalid-completion-signal", "completionSignal evidenceIds are required")
24
+ verifier = signal.get("verifier")
25
+ if not isinstance(verifier, dict) or not isinstance(verifier.get("id"), str) or not verifier["id"]:
26
+ raise LifecycleError("invalid-completion-signal", "completionSignal verifier.id is required")
27
+ if status == "FAIL":
28
+ raise LifecycleError("completion-signal-not-ready", "completionSignal status FAIL cannot finalize the run")
29
+ waiver = signal.get("waiver")
30
+ if status == "WAIVED":
31
+ _validate_waiver(waiver)
32
+ elif waiver is not None:
33
+ raise LifecycleError("invalid-completion-signal", "completionSignal waiver is only allowed for WAIVED status")
34
+ return {
35
+ "schemaVersion": "agent-completion-signal-validation.v1",
36
+ "status": "PASS",
37
+ "signalStatus": status,
38
+ "runId": signal.get("runId"),
39
+ "packageId": signal.get("packageId"),
40
+ "planRevision": signal.get("planRevision"),
41
+ "planDigest": signal.get("planDigest"),
42
+ "sourceRevision": signal.get("sourceRevision"),
43
+ "evidenceIds": evidence_ids,
44
+ "waived": status == "WAIVED",
45
+ "signalDigest": canonical_digest(signal),
46
+ }
47
+
48
+
49
+ def _require_lineage(signal: dict[str, Any], state: dict[str, Any]) -> None:
50
+ expected = {
51
+ "runId": state.get("runId"),
52
+ "packageId": state.get("packageId"),
53
+ "planRevision": state.get("planRevision"),
54
+ "planDigest": state.get("planDigest"),
55
+ "sourceRevision": state.get("sourceRevision"),
56
+ }
57
+ for key, value in expected.items():
58
+ if signal.get(key) != value:
59
+ raise LifecycleError("completion-signal-lineage-mismatch", f"completionSignal {key} mismatch")
60
+
61
+
62
+ def _validate_waiver(waiver: Any) -> None:
63
+ if not isinstance(waiver, dict):
64
+ raise LifecycleError("invalid-completion-signal-waiver", "WAIVED completionSignal requires waiver")
65
+ for key in ("reason", "approvedBy"):
66
+ if not isinstance(waiver.get(key), str) or not waiver[key]:
67
+ raise LifecycleError("invalid-completion-signal-waiver", f"waiver.{key} is required")
68
+ if not _string_list(waiver.get("evidenceIds"), label="waiver.evidenceIds"):
69
+ raise LifecycleError("invalid-completion-signal-waiver", "waiver.evidenceIds are required")
70
+
71
+
72
+ def _string_list(value: Any, *, label: str) -> list[str]:
73
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
74
+ raise LifecycleError("invalid-completion-signal", f"{label} must be a list of non-empty strings")
75
+ return value
@@ -0,0 +1,48 @@
1
+ """Validate portable SDD specification envelopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest
8
+ from agent_lifecycle.specification.completion_check import validate_completion_check
9
+
10
+ SDD_TIERS = {"S0", "S1", "S2"}
11
+ SPEC_STATUSES = {"DRAFT", "READY", "FROZEN", "REOPENED"}
12
+
13
+
14
+ def validate_specification(specification: dict[str, Any]) -> dict[str, Any]:
15
+ tier = specification.get("tier")
16
+ if tier not in SDD_TIERS:
17
+ raise LifecycleError("invalid-specification", "specification tier must be S0, S1, or S2")
18
+ status = specification.get("status")
19
+ if status not in SPEC_STATUSES:
20
+ raise LifecycleError("invalid-specification", "specification status is invalid")
21
+ requirements = specification.get("requirements")
22
+ if not isinstance(requirements, list) or not requirements:
23
+ raise LifecycleError("invalid-specification", "specification requirements are required")
24
+ ids = [_requirement_id(item) for item in requirements]
25
+ if len(ids) != len(set(ids)):
26
+ raise LifecycleError("invalid-specification", "requirement ids must be unique")
27
+ result = {
28
+ "schemaVersion": "agent-specification-validation.v1",
29
+ "tier": tier,
30
+ "status": status,
31
+ "requirementCount": len(ids),
32
+ "requirementSetDigest": canonical_digest({"requirements": ids}),
33
+ }
34
+ check = specification.get("completionCheck")
35
+ if check is not None:
36
+ check_validation = validate_completion_check(check)
37
+ result["completionCheckId"] = check_validation["checkId"]
38
+ result["completionCheckKind"] = check_validation["kind"]
39
+ result["completionCheckDigest"] = check_validation["checkDigest"]
40
+ return result
41
+
42
+
43
+ def _requirement_id(item: Any) -> str:
44
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not item["id"]:
45
+ raise LifecycleError("invalid-specification", "each requirement requires an id")
46
+ if item.get("required") is False:
47
+ raise LifecycleError("invalid-specification", "optional requirements are not accepted in frozen core")
48
+ return item["id"]
@@ -0,0 +1,79 @@
1
+ """Durable workflow controller primitives."""
2
+
3
+ from agent_lifecycle.workflow.controller import (
4
+ accept_task,
5
+ adopt_plan,
6
+ apply_budget_decision,
7
+ block_run,
8
+ check_lineage,
9
+ commit_task_result,
10
+ finalize_run,
11
+ next_action,
12
+ pause_for_budget_decision,
13
+ pause_for_external_action,
14
+ resolve_blocker,
15
+ resume_external_action,
16
+ select_auto_budget_action,
17
+ start_execution,
18
+ start_task,
19
+ status,
20
+ validate_budget_exceeded_policy,
21
+ )
22
+ from agent_lifecycle.workflow.managed_runner import run_managed_lifecycle_step
23
+ from agent_lifecycle.workflow.bug_forensics_gates import (
24
+ build_bug_forensics_gate_receipt,
25
+ bug_forensics_activated,
26
+ require_bug_forensics_gate_pass,
27
+ validate_bug_forensics_gate_receipt,
28
+ )
29
+ from agent_lifecycle.workflow.final_proof_integrity import (
30
+ proof_integrity_required,
31
+ validate_final_proof_integrity,
32
+ )
33
+ from agent_lifecycle.workflow.leases import (
34
+ build_worker_lease_receipt,
35
+ classify_lease_status,
36
+ require_worker_lease_receipt_pass,
37
+ validate_worker_lease_receipt,
38
+ )
39
+ from agent_lifecycle.workflow.sandbox_policy import (
40
+ build_sandbox_requirement_policy,
41
+ require_task_sandbox_evidence_pass,
42
+ sandbox_evidence_required,
43
+ validate_task_sandbox_evidence,
44
+ )
45
+
46
+ __all__ = [
47
+ "accept_task",
48
+ "adopt_plan",
49
+ "apply_budget_decision",
50
+ "block_run",
51
+ "build_bug_forensics_gate_receipt",
52
+ "build_worker_lease_receipt",
53
+ "build_sandbox_requirement_policy",
54
+ "bug_forensics_activated",
55
+ "check_lineage",
56
+ "classify_lease_status",
57
+ "commit_task_result",
58
+ "finalize_run",
59
+ "next_action",
60
+ "pause_for_budget_decision",
61
+ "pause_for_external_action",
62
+ "proof_integrity_required",
63
+ "require_bug_forensics_gate_pass",
64
+ "require_task_sandbox_evidence_pass",
65
+ "require_worker_lease_receipt_pass",
66
+ "resolve_blocker",
67
+ "resume_external_action",
68
+ "run_managed_lifecycle_step",
69
+ "sandbox_evidence_required",
70
+ "select_auto_budget_action",
71
+ "start_execution",
72
+ "start_task",
73
+ "status",
74
+ "validate_budget_exceeded_policy",
75
+ "validate_bug_forensics_gate_receipt",
76
+ "validate_final_proof_integrity",
77
+ "validate_task_sandbox_evidence",
78
+ "validate_worker_lease_receipt",
79
+ ]
@@ -0,0 +1,74 @@
1
+ """Workflow artifact path and identity helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest
9
+ from agent_lifecycle.contracts.canonical import canonical_bytes
10
+ from agent_lifecycle.contracts.paths import normalize_repo_path
11
+
12
+
13
+ def package_root(state_path: Path, state: dict[str, Any]) -> Path:
14
+ raw = state.get("packageRoot")
15
+ if isinstance(raw, str) and raw:
16
+ return (state_path.parent / raw).resolve()
17
+ return state_path.parent
18
+
19
+
20
+ def artifact_path(task: dict[str, Any], role: str, attempt: int) -> str:
21
+ artifacts = task.get("artifactPaths")
22
+ if not isinstance(artifacts, dict) or not isinstance(artifacts.get(role), str):
23
+ raise LifecycleError(
24
+ "missing-artifact-template",
25
+ f"task {task.get('id')} has no {role} artifact template",
26
+ )
27
+ return normalize_repo_path(artifacts[role].replace("{attempt}", str(attempt)))
28
+
29
+
30
+ def artifact_identity(root: Path, path: str, value: dict[str, Any]) -> dict[str, Any]:
31
+ data = canonical_bytes(value) + b"\n"
32
+ actual = (root / path).read_bytes()
33
+ if actual != data:
34
+ raise LifecycleError(
35
+ "non-canonical-artifact",
36
+ f"artifact is not canonical JSON: {path}",
37
+ )
38
+ return {"path": path, "sha256": canonical_digest(value), "bytes": len(actual)}
39
+
40
+
41
+ def next_available_attempt(
42
+ state_path: Path,
43
+ state: dict[str, Any],
44
+ task: dict[str, Any],
45
+ ) -> int:
46
+ max_attempts = int(state.get("budgets", {}).get("maxTaskAttempts", 1))
47
+ current = int(task.get("attempt", 0))
48
+ for attempt in range(current + 1, max_attempts + 1):
49
+ if not attempt_artifacts_exist(state_path, state, task, attempt):
50
+ return attempt
51
+ raise LifecycleError(
52
+ "task-attempt-output-conflict",
53
+ f"task {task.get('id')} has no unoccupied attempt artifact path",
54
+ {"maxTaskAttempts": max_attempts},
55
+ )
56
+
57
+
58
+ def attempt_artifacts_exist(
59
+ state_path: Path,
60
+ state: dict[str, Any],
61
+ task: dict[str, Any],
62
+ attempt: int,
63
+ ) -> bool:
64
+ artifacts = task.get("artifactPaths", {})
65
+ if not isinstance(artifacts, dict):
66
+ return False
67
+ root = package_root(state_path, state)
68
+ for template in artifacts.values():
69
+ if not isinstance(template, str):
70
+ continue
71
+ rendered = normalize_repo_path(template.replace("{attempt}", str(attempt)))
72
+ if (root / rendered).exists():
73
+ return True
74
+ return False
@@ -0,0 +1,370 @@
1
+ """Budget-overrun pause, decision, and resume helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from agent_lifecycle.contracts import LifecycleError, read_json_object, write_json_create
9
+ from agent_lifecycle.contracts.paths import normalize_repo_path
10
+ from agent_lifecycle.workflow.artifacts import artifact_identity, package_root
11
+ from agent_lifecycle.workflow.budget_policy import (
12
+ ALLOWED_BUDGET_ACTIONS,
13
+ select_auto_budget_action,
14
+ validate_budget_exceeded_policy,
15
+ )
16
+ from agent_lifecycle.workflow.budget_receipts import (
17
+ build_budget_decision_receipt,
18
+ route_digest,
19
+ )
20
+ from agent_lifecycle.workflow.model_usage import (
21
+ UNSAFE_CRITICAL_REVIEW_CLASSES,
22
+ validate_attempt_model_route,
23
+ validate_task_model_usage_receipt,
24
+ )
25
+ from agent_lifecycle.workflow.operation_kernel import commit_state, load_for_update
26
+ from agent_lifecycle.workflow.query import status
27
+ from agent_lifecycle.workflow.selectors import find_task
28
+
29
+ def pause_for_budget_decision(
30
+ state_path: Path,
31
+ *,
32
+ task_id: str,
33
+ operation_id: str,
34
+ expected_revision: int,
35
+ source_revision: str,
36
+ usage_receipt_path: str,
37
+ budget_policy_path: str,
38
+ decision_receipt_path: str,
39
+ reason: str,
40
+ ) -> dict[str, Any]:
41
+ state = load_for_update(state_path, operation_id=operation_id, expected_revision=expected_revision)
42
+ if state["phase"] != "RUNNING":
43
+ raise LifecycleError("invalid-phase", "budget decision pause requires RUNNING phase")
44
+ if state.get("sourceRevision") != source_revision:
45
+ raise LifecycleError("source-revision-mismatch", "source revision mismatch")
46
+ task = find_task(state, task_id)
47
+ if task.get("status") != "RUNNING":
48
+ raise LifecycleError("invalid-task-status", f"task {task_id} is not RUNNING")
49
+ route = _attempt_route(task)
50
+ if route is None:
51
+ raise LifecycleError("budget-decision-not-required", "task has no model route")
52
+
53
+ root = package_root(state_path, state)
54
+ usage_rel = normalize_repo_path(usage_receipt_path, label="model usage receipt")
55
+ receipt = read_json_object(root / usage_rel, label="model usage receipt")
56
+ usage_identity = artifact_identity(root, usage_rel, receipt)
57
+ validation = validate_task_model_usage_receipt(state, task, receipt, fail_on_invalid=False)
58
+ if validation["status"] != "FAIL" or not _has_budget_failure(validation):
59
+ raise LifecycleError("budget-decision-not-required", "model usage receipt did not fail a budget guard")
60
+
61
+ policy_rel = normalize_repo_path(budget_policy_path, label="budget policy")
62
+ policy = read_json_object(root / policy_rel, label="budget policy")
63
+ policy_validation = validate_budget_exceeded_policy(policy)
64
+ action = "await-operator" if policy_validation["mode"] == "manual" else select_auto_budget_action(policy, task=task, route_decision=route)
65
+
66
+ decision_rel = normalize_repo_path(decision_receipt_path, label="budget decision receipt")
67
+ decision = build_budget_decision_receipt(
68
+ state=state,
69
+ task=task,
70
+ route_decision=route,
71
+ usage_receipt=receipt,
72
+ usage_identity=usage_identity,
73
+ policy=policy,
74
+ selected_action=action,
75
+ validation=validation,
76
+ expected_workflow_revision=state["stateRevision"] + 1,
77
+ )
78
+ write_json_create(root / decision_rel, decision)
79
+ decision_identity = artifact_identity(root, decision_rel, decision)
80
+ task["status"] = "WAITING_FOR_BUDGET_DECISION"
81
+ task["budgetDecision"] = {
82
+ **decision_identity,
83
+ "selectedAction": action,
84
+ "decisionMode": policy_validation["mode"],
85
+ "usageReceipt": usage_identity,
86
+ "usageValidation": validation,
87
+ }
88
+ if policy_validation["mode"] == "auto":
89
+ task["budgetAutoReroutes"] = int(task.get("budgetAutoReroutes", 0)) + 1
90
+ state["phase"] = "WAITING_FOR_BUDGET_DECISION"
91
+ state["blocker"] = {
92
+ "code": "BUDGET_DECISION_REQUIRED",
93
+ "reason": reason,
94
+ "resumePhase": "RUNNING",
95
+ "taskId": task_id,
96
+ "attempt": task.get("attempt"),
97
+ "allowedActions": policy["allowedActions"],
98
+ "decisionReceiptTarget": decision_rel,
99
+ "decisionReceipt": decision_identity,
100
+ }
101
+ commit_state(
102
+ state_path,
103
+ state,
104
+ operation_id=operation_id,
105
+ event_type="budget-decision-required",
106
+ payload={"taskId": task_id, "attempt": task.get("attempt"), "decisionReceipt": decision_identity},
107
+ )
108
+ return status(state_path)
109
+
110
+
111
+ def apply_budget_decision(
112
+ state_path: Path,
113
+ *,
114
+ task_id: str,
115
+ operation_id: str,
116
+ expected_revision: int,
117
+ source_revision: str,
118
+ decision_receipt_path: str,
119
+ action: str,
120
+ applied_receipt_path: str,
121
+ route_decision_path: str | None = None,
122
+ split_packet_path: str | None = None,
123
+ cap_deltas_path: str | None = None,
124
+ operator_identity_hash: str | None = None,
125
+ reason: str,
126
+ ) -> dict[str, Any]:
127
+ if action not in ALLOWED_BUDGET_ACTIONS:
128
+ raise LifecycleError("invalid-budget-action", "unsupported budget decision action", {"action": action})
129
+ state = load_for_update(state_path, operation_id=operation_id, expected_revision=expected_revision)
130
+ if state["phase"] != "WAITING_FOR_BUDGET_DECISION":
131
+ raise LifecycleError("invalid-phase", "budget decision apply requires WAITING_FOR_BUDGET_DECISION phase")
132
+ if state.get("sourceRevision") != source_revision:
133
+ raise LifecycleError("source-revision-mismatch", "source revision mismatch")
134
+ task = find_task(state, task_id)
135
+ if task.get("status") != "WAITING_FOR_BUDGET_DECISION":
136
+ raise LifecycleError("invalid-task-status", f"task {task_id} is not waiting for a budget decision")
137
+ blocker = _budget_blocker(state, task)
138
+ root = package_root(state_path, state)
139
+ pending_rel = normalize_repo_path(decision_receipt_path, label="budget decision receipt")
140
+ pending = read_json_object(root / pending_rel, label="budget decision receipt")
141
+ pending_identity = artifact_identity(root, pending_rel, pending)
142
+ _validate_pending_decision(state, task, pending, action)
143
+
144
+ next_route, next_route_identity = _read_next_route(root, route_decision_path, action, task)
145
+ split_identity = _read_split_packet(root, split_packet_path, action)
146
+ cap_deltas = _read_cap_deltas(root, cap_deltas_path, action)
147
+ _require_operator_identity(pending, operator_identity_hash)
148
+
149
+ receipt_rel = normalize_repo_path(applied_receipt_path, label="applied budget decision receipt")
150
+ applied = build_budget_decision_receipt(
151
+ state=state,
152
+ task=task,
153
+ route_decision=_attempt_route(task) or {},
154
+ usage_receipt={"identity": pending.get("usageReceiptDigest")},
155
+ usage_identity=dict(pending["usageReceipt"]),
156
+ policy={"mode": pending["decisionMode"], "allowedActions": pending["allowedActions"]},
157
+ selected_action=action,
158
+ validation={"checks": [{"id": pending["overrunReason"], "status": "FAIL"}]},
159
+ expected_workflow_revision=state["stateRevision"] + 1,
160
+ operator_identity_hash=operator_identity_hash,
161
+ prior_route_digest=str(pending["priorRouteDecisionDigest"]),
162
+ usage_receipt_digest=str(pending["usageReceiptDigest"]),
163
+ policy_digest=str(pending["policyDigest"]),
164
+ overrun_reason=str(pending["overrunReason"]),
165
+ next_route_decision=next_route,
166
+ next_route_identity=next_route_identity,
167
+ split_packet_identity=split_identity,
168
+ cap_deltas=cap_deltas,
169
+ )
170
+ applied["pendingDecisionReceipt"] = pending_identity
171
+ write_json_create(root / receipt_rel, applied)
172
+ applied_identity = artifact_identity(root, receipt_rel, applied)
173
+ _apply_action(
174
+ state,
175
+ task,
176
+ action=action,
177
+ next_route=next_route,
178
+ split_identity=split_identity,
179
+ applied_identity=applied_identity,
180
+ pending_identity=pending_identity,
181
+ reason=reason,
182
+ )
183
+ commit_state(
184
+ state_path,
185
+ state,
186
+ operation_id=operation_id,
187
+ event_type="budget-decision-applied",
188
+ payload={
189
+ "taskId": task_id,
190
+ "attempt": task.get("attempt"),
191
+ "action": action,
192
+ "previousBlocker": blocker,
193
+ "decisionReceipt": applied_identity,
194
+ },
195
+ )
196
+ return status(state_path)
197
+
198
+
199
+ def _apply_action(
200
+ state: dict[str, Any],
201
+ task: dict[str, Any],
202
+ *,
203
+ action: str,
204
+ next_route: dict[str, Any] | None,
205
+ split_identity: dict[str, Any] | None,
206
+ applied_identity: dict[str, Any],
207
+ pending_identity: dict[str, Any],
208
+ reason: str,
209
+ ) -> None:
210
+ task["budgetDecisionApplied"] = {
211
+ **applied_identity,
212
+ "selectedAction": action,
213
+ "pendingDecisionReceipt": pending_identity,
214
+ "reason": reason,
215
+ }
216
+ task["lastReason"] = reason
217
+ if action == "continue-same-route":
218
+ task["status"] = "RUNNING"
219
+ state["phase"] = "RUNNING"
220
+ state["blocker"] = None
221
+ return
222
+ if action in {"reroute-cheaper", "reroute-stronger"}:
223
+ assert next_route is not None
224
+ task["modelRoute"] = next_route
225
+ task.pop("attemptModelRoute", None)
226
+ task["status"] = "READY"
227
+ state["phase"] = "RUNNING"
228
+ state["blocker"] = None
229
+ return
230
+ task["status"] = "BLOCKED"
231
+ state["phase"] = "BLOCKED"
232
+ code = "BUDGET_SPLIT_REQUIRED" if action == "split-task" else "BUDGET_ABORTED"
233
+ state["blocker"] = {
234
+ "code": code,
235
+ "reason": reason,
236
+ "resumePhase": "RUNNING",
237
+ "taskId": task["id"],
238
+ "attempt": task.get("attempt"),
239
+ "selectedAction": action,
240
+ "decisionReceipt": applied_identity,
241
+ "splitPacketIdentity": split_identity,
242
+ }
243
+
244
+
245
+ def _budget_blocker(state: dict[str, Any], task: dict[str, Any]) -> dict[str, Any]:
246
+ blocker = state.get("blocker")
247
+ if not isinstance(blocker, dict) or blocker.get("code") != "BUDGET_DECISION_REQUIRED":
248
+ raise LifecycleError("budget-decision-not-required", "run is not blocked on a budget decision")
249
+ if blocker.get("taskId") != task["id"] or blocker.get("attempt") != task.get("attempt"):
250
+ raise LifecycleError("budget-decision-lineage-mismatch", "budget blocker does not match task attempt")
251
+ return dict(blocker)
252
+
253
+
254
+ def _validate_pending_decision(
255
+ state: dict[str, Any],
256
+ task: dict[str, Any],
257
+ receipt: dict[str, Any],
258
+ action: str,
259
+ ) -> None:
260
+ if receipt.get("schemaVersion") != "agent-lifecycle-budget-decision-receipt.v1":
261
+ raise LifecycleError("invalid-budget-decision-receipt", "unsupported budget decision receipt schema")
262
+ expected = {
263
+ "runId": state.get("runId"),
264
+ "packageId": state.get("packageId"),
265
+ "taskId": task.get("id"),
266
+ "attempt": task.get("attempt"),
267
+ "sourceRevision": state.get("sourceRevision"),
268
+ "expectedWorkflowRevision": state.get("stateRevision"),
269
+ }
270
+ for key, value in expected.items():
271
+ if receipt.get(key) != value:
272
+ raise LifecycleError("budget-decision-lineage-mismatch", f"budget decision receipt {key} mismatch")
273
+ if action not in receipt.get("allowedActions", []):
274
+ raise LifecycleError("budget-action-not-allowed", "budget decision action is not allowed", {"action": action})
275
+ selected = receipt.get("selectedAction")
276
+ if receipt.get("decisionMode") == "manual" and selected != "await-operator":
277
+ raise LifecycleError("invalid-budget-decision-receipt", "manual pending receipt must await operator action")
278
+ if receipt.get("decisionMode") == "auto" and selected != action:
279
+ raise LifecycleError("budget-action-mismatch", "auto budget decision must apply the selected action")
280
+ prior_route = _attempt_route(task)
281
+ if prior_route is not None and receipt.get("priorRouteDecisionDigest") != route_digest(prior_route):
282
+ raise LifecycleError("budget-decision-lineage-mismatch", "prior route decision digest mismatch")
283
+
284
+
285
+ def _read_next_route(
286
+ root: Path,
287
+ route_decision_path: str | None,
288
+ action: str,
289
+ task: dict[str, Any],
290
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
291
+ if action not in {"reroute-cheaper", "reroute-stronger"}:
292
+ if route_decision_path is not None:
293
+ raise LifecycleError("unexpected-route-decision", "route decision is only valid for reroute actions")
294
+ return None, None
295
+ if route_decision_path is None:
296
+ raise LifecycleError("route-decision-required", "reroute actions require --route-decision")
297
+ route_rel = normalize_repo_path(route_decision_path, label="route decision")
298
+ route = read_json_object(root / route_rel, label="route decision")
299
+ if route.get("schemaVersion") != "agent-lifecycle-model-route-decision.v1":
300
+ raise LifecycleError("invalid-model-route", "route decision schema is unsupported")
301
+ if not isinstance(route.get("modelClass"), str) or not route["modelClass"]:
302
+ raise LifecycleError("invalid-model-route", "route decision modelClass is required")
303
+ _require_critical_safety(task, route, action)
304
+ candidate = {**task, "modelRoute": route}
305
+ validate_attempt_model_route(candidate)
306
+ return route, artifact_identity(root, route_rel, route)
307
+
308
+
309
+ def _read_split_packet(root: Path, split_packet_path: str | None, action: str) -> dict[str, Any] | None:
310
+ if action != "split-task":
311
+ if split_packet_path is not None:
312
+ raise LifecycleError("unexpected-split-packet", "split packet is only valid for split-task")
313
+ return None
314
+ if split_packet_path is None:
315
+ raise LifecycleError("split-packet-required", "split-task requires --split-packet")
316
+ packet_rel = normalize_repo_path(split_packet_path, label="split packet")
317
+ packet = read_json_object(root / packet_rel, label="split packet")
318
+ return artifact_identity(root, packet_rel, packet)
319
+
320
+
321
+ def _read_cap_deltas(root: Path, cap_deltas_path: str | None, action: str) -> dict[str, Any]:
322
+ if action == "continue-same-route" and cap_deltas_path is None:
323
+ raise LifecycleError("cap-deltas-required", "continue-same-route requires explicit cap deltas")
324
+ if cap_deltas_path is None:
325
+ return {}
326
+ deltas_rel = normalize_repo_path(cap_deltas_path, label="cap deltas")
327
+ deltas = read_json_object(root / deltas_rel, label="cap deltas")
328
+ if not isinstance(deltas, dict):
329
+ raise LifecycleError("invalid-cap-deltas", "cap deltas must be an object")
330
+ return deltas
331
+
332
+
333
+ def _require_operator_identity(receipt: dict[str, Any], operator_identity_hash: str | None) -> None:
334
+ if receipt.get("decisionMode") != "manual":
335
+ return
336
+ if not isinstance(operator_identity_hash, str) or not operator_identity_hash:
337
+ raise LifecycleError("operator-identity-required", "manual budget decision requires operator identity hash")
338
+
339
+
340
+ def _require_critical_safety(task: dict[str, Any], next_route: dict[str, Any], action: str) -> None:
341
+ prior = _attempt_route(task) or {}
342
+ if prior.get("criticalReview") is not True:
343
+ return
344
+ next_class = str(next_route.get("modelClass", ""))
345
+ if action == "reroute-cheaper" or next_class in UNSAFE_CRITICAL_REVIEW_CLASSES:
346
+ raise LifecycleError(
347
+ "budget-critical-downgrade",
348
+ "critical review route cannot be rerouted to a cheaper or compact class",
349
+ {"modelClass": next_class},
350
+ )
351
+
352
+
353
+ def _attempt_route(task: dict[str, Any]) -> dict[str, Any] | None:
354
+ route = task.get("attemptModelRoute")
355
+ if isinstance(route, dict) and route:
356
+ return route
357
+ route = task.get("modelRoute")
358
+ if isinstance(route, dict) and route:
359
+ return route
360
+ return None
361
+
362
+
363
+ def _has_budget_failure(validation: dict[str, Any]) -> bool:
364
+ for check in validation.get("checks", []):
365
+ if not isinstance(check, dict) or check.get("status") != "FAIL":
366
+ continue
367
+ check_id = str(check.get("id", ""))
368
+ if check_id == "route-max-billable-tokens" or check_id.startswith("hard-ceiling-"):
369
+ return True
370
+ return False