constraintloop 0.1.0__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.
- constraintloop/__init__.py +7 -0
- constraintloop/__main__.py +4 -0
- constraintloop/cli.py +485 -0
- constraintloop/config.py +53 -0
- constraintloop/digest.py +233 -0
- constraintloop/engine.py +466 -0
- constraintloop/environment.py +50 -0
- constraintloop/eval_corpus.py +46 -0
- constraintloop/evaluators.py +334 -0
- constraintloop/hooks.py +335 -0
- constraintloop/loops.py +334 -0
- constraintloop/models.py +397 -0
- constraintloop/native_cli_evaluator.py +464 -0
- constraintloop/py.typed +1 -0
- constraintloop/runners.py +290 -0
- constraintloop/scaffold.py +181 -0
- constraintloop/setup_hooks.py +191 -0
- constraintloop/state.py +225 -0
- constraintloop-0.1.0.dist-info/METADATA +371 -0
- constraintloop-0.1.0.dist-info/RECORD +23 -0
- constraintloop-0.1.0.dist-info/WHEEL +4 -0
- constraintloop-0.1.0.dist-info/entry_points.txt +5 -0
- constraintloop-0.1.0.dist-info/licenses/LICENSE +21 -0
constraintloop/hooks.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Normalize agent hook payloads into one constraint lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from constraintloop.config import ContractError, load_contract
|
|
12
|
+
from constraintloop.engine import ConstraintEngine, blocking_results, format_summary
|
|
13
|
+
from constraintloop.loops import run_cycle
|
|
14
|
+
from constraintloop.models import LoopState, Phase, Verdict
|
|
15
|
+
from constraintloop.state import advisory_acknowledgment_reason, load_session, save_session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handle_hook(
|
|
19
|
+
project_root: Path,
|
|
20
|
+
adapter: str,
|
|
21
|
+
event: str,
|
|
22
|
+
payload: dict[str, Any],
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
session_id = str(
|
|
25
|
+
payload.get("session_id")
|
|
26
|
+
or payload.get("sessionId")
|
|
27
|
+
or payload.get("conversation_id")
|
|
28
|
+
or "default"
|
|
29
|
+
)
|
|
30
|
+
state = load_session(project_root, session_id)
|
|
31
|
+
|
|
32
|
+
if event == "user-prompt":
|
|
33
|
+
prompt = payload.get("prompt") or payload.get("user_prompt")
|
|
34
|
+
if not prompt and isinstance(payload.get("input"), str):
|
|
35
|
+
prompt = payload["input"]
|
|
36
|
+
if isinstance(prompt, str) and prompt.strip():
|
|
37
|
+
if _is_hook_feedback(prompt):
|
|
38
|
+
state["last_hook_feedback"] = prompt.strip()[-8000:]
|
|
39
|
+
save_session(project_root, session_id, state)
|
|
40
|
+
return _context_response(
|
|
41
|
+
adapter, event, "ConstraintLoop captured evaluator feedback."
|
|
42
|
+
)
|
|
43
|
+
state["goal"] = prompt.strip()[-8000:]
|
|
44
|
+
save_session(project_root, session_id, state)
|
|
45
|
+
return _context_response(adapter, event, "ConstraintLoop captured the task goal.")
|
|
46
|
+
|
|
47
|
+
if event == "pre-tool":
|
|
48
|
+
serialized = json.dumps(
|
|
49
|
+
payload.get("tool_input", payload.get("toolInput", payload)),
|
|
50
|
+
sort_keys=True,
|
|
51
|
+
)
|
|
52
|
+
if _protected_mutation(payload, serialized):
|
|
53
|
+
return _deny_response(
|
|
54
|
+
adapter,
|
|
55
|
+
"Agent writes to protected quality policy or creates a local exception. "
|
|
56
|
+
"Ask the human to make this change outside the agent session.",
|
|
57
|
+
)
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
contract, _ = load_contract(project_root)
|
|
62
|
+
except ContractError as exc:
|
|
63
|
+
if event == "stop":
|
|
64
|
+
return _block_response(adapter, str(exc))
|
|
65
|
+
return _context_response(adapter, event, str(exc))
|
|
66
|
+
|
|
67
|
+
if event == "session-start":
|
|
68
|
+
required = [
|
|
69
|
+
constraint_id
|
|
70
|
+
for constraint_id, spec in contract.constraints.items()
|
|
71
|
+
if spec.enabled and spec.enforcement.value == "required"
|
|
72
|
+
]
|
|
73
|
+
return _context_response(
|
|
74
|
+
adapter,
|
|
75
|
+
event,
|
|
76
|
+
"ConstraintLoop completion contract is active. Required gates: "
|
|
77
|
+
+ (", ".join(required) if required else "none")
|
|
78
|
+
+ ". Do not edit the contract or create waivers.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if event == "post-tool":
|
|
82
|
+
record = ConstraintEngine(
|
|
83
|
+
project_root,
|
|
84
|
+
contract,
|
|
85
|
+
goal=state.get("goal"),
|
|
86
|
+
agent_adapter=adapter,
|
|
87
|
+
).run(Phase.CHANGE)
|
|
88
|
+
if record.results:
|
|
89
|
+
return _context_response(adapter, event, format_summary(record))
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
if event == "pre-compact":
|
|
93
|
+
return _context_response(
|
|
94
|
+
adapter,
|
|
95
|
+
event,
|
|
96
|
+
"ConstraintLoop remains authoritative at completion. Run or repair all required "
|
|
97
|
+
"stop gates before claiming the task is complete.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if event != "stop":
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
record = ConstraintEngine(
|
|
104
|
+
project_root,
|
|
105
|
+
contract,
|
|
106
|
+
goal=state.get("goal"),
|
|
107
|
+
agent_adapter=adapter,
|
|
108
|
+
).run(Phase.STOP)
|
|
109
|
+
failures = blocking_results(record)
|
|
110
|
+
completion_loop = next(
|
|
111
|
+
(loop_name for loop_name, config in contract.loops.items() if config.phase == Phase.STOP),
|
|
112
|
+
None,
|
|
113
|
+
)
|
|
114
|
+
cycle = (
|
|
115
|
+
run_cycle(
|
|
116
|
+
project_root,
|
|
117
|
+
contract,
|
|
118
|
+
completion_loop,
|
|
119
|
+
record=record,
|
|
120
|
+
goal=state.get("goal"),
|
|
121
|
+
agent_adapter=adapter,
|
|
122
|
+
)
|
|
123
|
+
if completion_loop is not None
|
|
124
|
+
else None
|
|
125
|
+
)
|
|
126
|
+
advisories = [
|
|
127
|
+
result
|
|
128
|
+
for result in record.results
|
|
129
|
+
if not result.blocks
|
|
130
|
+
and result.verdict
|
|
131
|
+
not in {
|
|
132
|
+
Verdict.PASS,
|
|
133
|
+
Verdict.SKIPPED,
|
|
134
|
+
Verdict.WAIVED,
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
if not failures:
|
|
138
|
+
state["attempts"] = 0
|
|
139
|
+
state.pop("failed_snapshot", None)
|
|
140
|
+
if advisories:
|
|
141
|
+
snapshot = _result_snapshot(advisories)
|
|
142
|
+
summary = format_summary(record, include_output=True)
|
|
143
|
+
if state.get("advisory_feedback_snapshot") != snapshot:
|
|
144
|
+
state["advisory_feedback_snapshot"] = snapshot
|
|
145
|
+
save_session(project_root, session_id, state)
|
|
146
|
+
return _block_response(
|
|
147
|
+
adapter,
|
|
148
|
+
summary
|
|
149
|
+
+ "\nAdvisory feedback requires an agent disposition. Address it until fresh "
|
|
150
|
+
"evidence passes, or record why no change is appropriate with "
|
|
151
|
+
'`constraintloop acknowledge CONSTRAINT --reason "..."`, then try '
|
|
152
|
+
"completion again.",
|
|
153
|
+
)
|
|
154
|
+
reasons = {
|
|
155
|
+
result.constraint_id: advisory_acknowledgment_reason(project_root, result)
|
|
156
|
+
for result in advisories
|
|
157
|
+
}
|
|
158
|
+
missing = [constraint_id for constraint_id, reason in reasons.items() if not reason]
|
|
159
|
+
if missing:
|
|
160
|
+
return _block_response(
|
|
161
|
+
adapter,
|
|
162
|
+
summary
|
|
163
|
+
+ "\nAdvisory feedback was delivered but has no snapshot-bound disposition "
|
|
164
|
+
f"for: {', '.join(missing)}. Address it or explicitly acknowledge it.",
|
|
165
|
+
)
|
|
166
|
+
save_session(project_root, session_id, state)
|
|
167
|
+
return _allow_response(
|
|
168
|
+
adapter,
|
|
169
|
+
summary
|
|
170
|
+
+ "\nAdvisory feedback was explicitly acknowledged for this exact evidence: "
|
|
171
|
+
+ "; ".join(
|
|
172
|
+
f"{constraint_id}: {reason}" for constraint_id, reason in reasons.items()
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
state.pop("advisory_feedback_snapshot", None)
|
|
176
|
+
save_session(project_root, session_id, state)
|
|
177
|
+
return _allow_response(adapter, format_summary(record, include_output=True))
|
|
178
|
+
|
|
179
|
+
if cycle is not None:
|
|
180
|
+
summary = format_summary(record, include_output=True)
|
|
181
|
+
detail = (
|
|
182
|
+
f"\nLoop {cycle.loop}: {cycle.state.value}; "
|
|
183
|
+
f"repair attempt {cycle.repair_attempt}. {cycle.next_action}"
|
|
184
|
+
)
|
|
185
|
+
if cycle.state in {LoopState.REPAIR, LoopState.WAITING}:
|
|
186
|
+
return _block_response(adapter, summary + detail)
|
|
187
|
+
return _human_required_response(adapter, summary + detail)
|
|
188
|
+
|
|
189
|
+
snapshot = _result_snapshot(failures)
|
|
190
|
+
attempts = int(state.get("attempts", 0)) + 1 if state.get("failed_snapshot") == snapshot else 1
|
|
191
|
+
state["attempts"] = attempts
|
|
192
|
+
state["failed_snapshot"] = snapshot
|
|
193
|
+
save_session(project_root, session_id, state)
|
|
194
|
+
summary = format_summary(record, include_output=True)
|
|
195
|
+
if attempts <= contract.settings.max_auto_retries:
|
|
196
|
+
return _block_response(
|
|
197
|
+
adapter,
|
|
198
|
+
summary + f"\nRepair the failures and try again "
|
|
199
|
+
f"({attempts}/{contract.settings.max_auto_retries} automatic retries).",
|
|
200
|
+
)
|
|
201
|
+
return _human_required_response(
|
|
202
|
+
adapter,
|
|
203
|
+
summary + "\nThe same evidence failed repeatedly. Stop automatic repair and ask a human "
|
|
204
|
+
"to fix the implementation, revise the contract, or create a local waiver.",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _result_snapshot(results: list[Any]) -> str:
|
|
209
|
+
return hashlib.sha256(
|
|
210
|
+
json.dumps(
|
|
211
|
+
[(item.constraint_id, item.input_digest, item.verdict.value) for item in results],
|
|
212
|
+
sort_keys=True,
|
|
213
|
+
).encode()
|
|
214
|
+
).hexdigest()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_NATIVE_EVENTS = {
|
|
218
|
+
"claude": {
|
|
219
|
+
"session-start": "SessionStart",
|
|
220
|
+
"user-prompt": "UserPromptSubmit",
|
|
221
|
+
"post-tool": "PostToolUse",
|
|
222
|
+
"pre-compact": "PreCompact",
|
|
223
|
+
"stop": "Stop",
|
|
224
|
+
},
|
|
225
|
+
"codex": {
|
|
226
|
+
"session-start": "SessionStart",
|
|
227
|
+
"user-prompt": "UserPromptSubmit",
|
|
228
|
+
"post-tool": "PostToolUse",
|
|
229
|
+
"pre-compact": "PreCompact",
|
|
230
|
+
"stop": "Stop",
|
|
231
|
+
},
|
|
232
|
+
"gemini": {
|
|
233
|
+
"session-start": "SessionStart",
|
|
234
|
+
"user-prompt": "BeforeAgent",
|
|
235
|
+
"post-tool": "AfterTool",
|
|
236
|
+
"pre-compact": "PreCompress",
|
|
237
|
+
"stop": "AfterAgent",
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _context_response(adapter: str, event: str, text: str) -> dict[str, Any]:
|
|
243
|
+
native_event = _NATIVE_EVENTS[adapter].get(event)
|
|
244
|
+
if event == "pre-compact" and adapter in {"claude", "codex"}:
|
|
245
|
+
return {"systemMessage": text}
|
|
246
|
+
if native_event is None:
|
|
247
|
+
return {"systemMessage": text}
|
|
248
|
+
return {
|
|
249
|
+
"hookSpecificOutput": {
|
|
250
|
+
"hookEventName": native_event,
|
|
251
|
+
"additionalContext": text,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _deny_response(adapter: str, reason: str) -> dict[str, Any]:
|
|
257
|
+
if adapter == "gemini":
|
|
258
|
+
return {"decision": "deny", "reason": reason}
|
|
259
|
+
return {
|
|
260
|
+
"hookSpecificOutput": {
|
|
261
|
+
"hookEventName": "PreToolUse",
|
|
262
|
+
"permissionDecision": "deny",
|
|
263
|
+
"permissionDecisionReason": reason,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _block_response(adapter: str, reason: str) -> dict[str, Any]:
|
|
269
|
+
if adapter == "gemini":
|
|
270
|
+
return {"decision": "deny", "reason": reason}
|
|
271
|
+
return {"decision": "block", "reason": reason}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _allow_response(adapter: str, summary: str) -> dict[str, Any]:
|
|
275
|
+
if adapter == "gemini":
|
|
276
|
+
return {"decision": "allow", "systemMessage": summary}
|
|
277
|
+
return {"continue": True, "systemMessage": summary}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _human_required_response(adapter: str, reason: str) -> dict[str, Any]:
|
|
281
|
+
if adapter == "gemini":
|
|
282
|
+
return {"decision": "deny", "reason": reason}
|
|
283
|
+
return {"continue": False, "stopReason": reason, "systemMessage": reason}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_hook_feedback(prompt: str) -> bool:
|
|
287
|
+
stripped = prompt.strip()
|
|
288
|
+
return stripped.startswith("<hook_prompt ") and stripped.endswith("</hook_prompt>")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _protected_mutation(payload: dict[str, Any], serialized: str) -> bool:
|
|
292
|
+
protected = (
|
|
293
|
+
"constraintloop." + "yml",
|
|
294
|
+
"constraintloop." + "yaml",
|
|
295
|
+
".constraintloop/" + "secrets.env",
|
|
296
|
+
)
|
|
297
|
+
tool_name = str(payload.get("tool_name") or payload.get("toolName") or "").lower()
|
|
298
|
+
tool_input = payload.get("tool_input", payload.get("toolInput", payload))
|
|
299
|
+
if not isinstance(tool_input, dict):
|
|
300
|
+
tool_input = {}
|
|
301
|
+
text_values = [value for value in tool_input.values() if isinstance(value, str)]
|
|
302
|
+
if "patch" in tool_name or any(value.startswith("*** Begin Patch") for value in text_values):
|
|
303
|
+
headers = re.findall(
|
|
304
|
+
r"^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$",
|
|
305
|
+
"\n".join(text_values),
|
|
306
|
+
re.MULTILINE,
|
|
307
|
+
)
|
|
308
|
+
return any(any(name in header for name in protected) for header in headers)
|
|
309
|
+
command = tool_input.get("cmd") or tool_input.get("command")
|
|
310
|
+
if isinstance(command, str):
|
|
311
|
+
forbidden_subcommand = "constraintloop " + "waive"
|
|
312
|
+
if forbidden_subcommand in command:
|
|
313
|
+
return True
|
|
314
|
+
mutator = re.search(
|
|
315
|
+
r"(^|[;&|]\s*)(rm|mv|cp|tee|touch|truncate|sed\s+-i|perl\s+-i)\b|[>]",
|
|
316
|
+
command,
|
|
317
|
+
)
|
|
318
|
+
if mutator and any(name in command for name in protected):
|
|
319
|
+
return True
|
|
320
|
+
if any("*** " in value for value in text_values):
|
|
321
|
+
headers = re.findall(
|
|
322
|
+
r"^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$",
|
|
323
|
+
"\n".join(text_values),
|
|
324
|
+
re.MULTILINE,
|
|
325
|
+
)
|
|
326
|
+
return any(any(name in header for name in protected) for header in headers)
|
|
327
|
+
if any(marker in tool_name for marker in ("write", "edit", "delete", "move")):
|
|
328
|
+
path_values = [
|
|
329
|
+
value
|
|
330
|
+
for key, value in tool_input.items()
|
|
331
|
+
if key.lower() in {"path", "file_path", "filepath", "destination", "target"}
|
|
332
|
+
and isinstance(value, str)
|
|
333
|
+
]
|
|
334
|
+
return any(any(name in value for name in protected) for value in path_values)
|
|
335
|
+
return False
|
constraintloop/loops.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Bounded, journaled convergence-loop transitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from constraintloop.config import contract_digest
|
|
17
|
+
from constraintloop.digest import constraint_input_digest
|
|
18
|
+
from constraintloop.engine import ConstraintEngine, blocking_results
|
|
19
|
+
from constraintloop.models import (
|
|
20
|
+
Contract,
|
|
21
|
+
CycleResult,
|
|
22
|
+
EvidenceRecord,
|
|
23
|
+
LoopConfig,
|
|
24
|
+
LoopJournal,
|
|
25
|
+
LoopState,
|
|
26
|
+
Phase,
|
|
27
|
+
Verdict,
|
|
28
|
+
)
|
|
29
|
+
from constraintloop.state import _read_json, _write_json, _write_lock, cache_root
|
|
30
|
+
|
|
31
|
+
CYCLE_EXIT_CODES = {
|
|
32
|
+
LoopState.PASSED: 0,
|
|
33
|
+
LoopState.REPAIR: 10,
|
|
34
|
+
LoopState.WAITING: 11,
|
|
35
|
+
LoopState.HUMAN_REQUIRED: 12,
|
|
36
|
+
LoopState.BUDGET_EXHAUSTED: 13,
|
|
37
|
+
LoopState.ERROR: 14,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoopError(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_name(name: str) -> str:
|
|
46
|
+
safe = "".join(char if char.isalnum() or char in "-_." else "_" for char in name)
|
|
47
|
+
if not safe or safe != name:
|
|
48
|
+
raise LoopError(f"Invalid loop name {name!r}")
|
|
49
|
+
return safe
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def loop_root(project_root: Path) -> Path:
|
|
53
|
+
return cache_root(project_root) / "loops"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def journal_path(project_root: Path, loop_name: str) -> Path:
|
|
57
|
+
return loop_root(project_root) / f"{_safe_name(loop_name)}.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def lease_path(project_root: Path, loop_name: str) -> Path:
|
|
61
|
+
return loop_root(project_root) / f"{_safe_name(loop_name)}.lease.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def evidence_snapshot(record: EvidenceRecord) -> str:
|
|
65
|
+
payload = [
|
|
66
|
+
{
|
|
67
|
+
"constraint_id": item.constraint_id,
|
|
68
|
+
"input_digest": item.input_digest,
|
|
69
|
+
"verdict": item.verdict.value,
|
|
70
|
+
"findings": [
|
|
71
|
+
finding.model_dump(mode="json", exclude_none=True) for finding in item.findings
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
for item in record.results
|
|
75
|
+
]
|
|
76
|
+
raw = json.dumps(
|
|
77
|
+
{"contract_digest": record.contract_digest, "results": payload},
|
|
78
|
+
sort_keys=True,
|
|
79
|
+
separators=(",", ":"),
|
|
80
|
+
).encode()
|
|
81
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _input_snapshot(project_root: Path, contract: Contract, config: LoopConfig) -> str:
|
|
85
|
+
identity = contract_digest(contract)
|
|
86
|
+
inputs = [
|
|
87
|
+
(
|
|
88
|
+
constraint_id,
|
|
89
|
+
constraint_input_digest(
|
|
90
|
+
project_root,
|
|
91
|
+
constraint_id,
|
|
92
|
+
spec,
|
|
93
|
+
contract_digest=identity,
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
for constraint_id, spec in contract.constraints.items()
|
|
97
|
+
if spec.enabled and config.phase in spec.phases
|
|
98
|
+
]
|
|
99
|
+
return hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_cycle(
|
|
103
|
+
project_root: Path,
|
|
104
|
+
contract: Contract,
|
|
105
|
+
loop_name: str,
|
|
106
|
+
*,
|
|
107
|
+
record: EvidenceRecord | None = None,
|
|
108
|
+
now: float | None = None,
|
|
109
|
+
goal: str | None = None,
|
|
110
|
+
agent_adapter: str | None = None,
|
|
111
|
+
) -> CycleResult:
|
|
112
|
+
"""Execute exactly one bounded transition and persist it atomically."""
|
|
113
|
+
if loop_name not in contract.loops:
|
|
114
|
+
raise LoopError(f"Unknown loop {loop_name!r}")
|
|
115
|
+
config = contract.loops[loop_name]
|
|
116
|
+
current_time = time.time() if now is None else now
|
|
117
|
+
identity = contract_digest(contract)
|
|
118
|
+
path = journal_path(project_root, loop_name)
|
|
119
|
+
with _write_lock(path):
|
|
120
|
+
raw = _read_json(path, {})
|
|
121
|
+
try:
|
|
122
|
+
journal = LoopJournal.model_validate(raw)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
if raw:
|
|
125
|
+
raise LoopError(f"Loop journal is corrupt: {path}") from exc
|
|
126
|
+
journal = LoopJournal(
|
|
127
|
+
loop=loop_name,
|
|
128
|
+
contract_digest=identity,
|
|
129
|
+
started_at=current_time,
|
|
130
|
+
updated_at=current_time,
|
|
131
|
+
)
|
|
132
|
+
if journal.contract_digest != identity:
|
|
133
|
+
journal = LoopJournal(
|
|
134
|
+
loop=loop_name,
|
|
135
|
+
contract_digest=identity,
|
|
136
|
+
started_at=current_time,
|
|
137
|
+
updated_at=current_time,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
input_snapshot = _input_snapshot(project_root, contract, config)
|
|
141
|
+
last_input = journal.input_snapshot
|
|
142
|
+
if (
|
|
143
|
+
record is None
|
|
144
|
+
and journal.prior_state == LoopState.WAITING
|
|
145
|
+
and last_input == input_snapshot
|
|
146
|
+
and current_time - journal.updated_at < config.interval_seconds
|
|
147
|
+
and journal.last_result is not None
|
|
148
|
+
):
|
|
149
|
+
previous = CycleResult.model_validate(journal.last_result)
|
|
150
|
+
result = previous.model_copy(
|
|
151
|
+
update={
|
|
152
|
+
"observation": journal.observation + 1,
|
|
153
|
+
"wake_after_seconds": max(
|
|
154
|
+
0.0, config.interval_seconds - (current_time - journal.updated_at)
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
journal.observation = result.observation
|
|
159
|
+
journal.last_result = result.model_dump(mode="json")
|
|
160
|
+
journal.input_snapshot = input_snapshot
|
|
161
|
+
_write_json(path, journal.model_dump(mode="json"))
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
if record is None:
|
|
165
|
+
record = ConstraintEngine(
|
|
166
|
+
project_root,
|
|
167
|
+
contract,
|
|
168
|
+
use_cache=config.phase != Phase.CI,
|
|
169
|
+
allow_waivers=config.phase != Phase.CI,
|
|
170
|
+
goal=goal,
|
|
171
|
+
agent_adapter=agent_adapter,
|
|
172
|
+
refresh_pending=True,
|
|
173
|
+
).run(config.phase)
|
|
174
|
+
|
|
175
|
+
snapshot = evidence_snapshot(record)
|
|
176
|
+
observation = journal.observation + 1
|
|
177
|
+
repair_attempt = journal.repair_attempt
|
|
178
|
+
unchanged_repairs = journal.unchanged_repairs
|
|
179
|
+
if journal.prior_state == LoopState.REPAIR:
|
|
180
|
+
repair_attempt += 1
|
|
181
|
+
unchanged_repairs = unchanged_repairs + 1 if journal.prior_snapshot == snapshot else 0
|
|
182
|
+
|
|
183
|
+
required = blocking_results(record)
|
|
184
|
+
pending = [item for item in required if item.verdict == Verdict.PENDING]
|
|
185
|
+
unreliable = [
|
|
186
|
+
item for item in required if item.verdict in {Verdict.ERROR, Verdict.UNCERTAIN}
|
|
187
|
+
]
|
|
188
|
+
blocking_ids = [item.constraint_id for item in required]
|
|
189
|
+
elapsed = current_time - journal.started_at
|
|
190
|
+
|
|
191
|
+
if not required:
|
|
192
|
+
state = LoopState.PASSED
|
|
193
|
+
action = "Fresh required evidence passes. Stop."
|
|
194
|
+
wake = 0.0
|
|
195
|
+
elif unreliable:
|
|
196
|
+
state = LoopState.ERROR
|
|
197
|
+
action = "Constraint evaluation is unreliable. Inspect evidence and require a human."
|
|
198
|
+
wake = 0.0
|
|
199
|
+
elif elapsed >= config.max_duration_seconds:
|
|
200
|
+
state = LoopState.BUDGET_EXHAUSTED
|
|
201
|
+
action = "The loop duration budget is exhausted. Require a human decision."
|
|
202
|
+
wake = 0.0
|
|
203
|
+
elif pending:
|
|
204
|
+
state = LoopState.WAITING
|
|
205
|
+
action = "Evidence is pending. Make no edits and run one cycle after the wake interval."
|
|
206
|
+
wake = config.interval_seconds
|
|
207
|
+
elif repair_attempt >= config.max_repair_attempts:
|
|
208
|
+
state = LoopState.BUDGET_EXHAUSTED
|
|
209
|
+
action = "The repair-attempt budget is exhausted. Require a human decision."
|
|
210
|
+
wake = 0.0
|
|
211
|
+
elif unchanged_repairs >= config.max_unchanged_repairs:
|
|
212
|
+
state = LoopState.HUMAN_REQUIRED
|
|
213
|
+
action = "Repairs left evidence unchanged. Require a human decision."
|
|
214
|
+
wake = 0.0
|
|
215
|
+
else:
|
|
216
|
+
state = LoopState.REPAIR
|
|
217
|
+
action = "Repair only the listed blocking constraints, then run exactly one new cycle."
|
|
218
|
+
wake = 0.0
|
|
219
|
+
|
|
220
|
+
result = CycleResult(
|
|
221
|
+
loop=loop_name,
|
|
222
|
+
state=state,
|
|
223
|
+
snapshot=snapshot,
|
|
224
|
+
observation=observation,
|
|
225
|
+
repair_attempt=repair_attempt,
|
|
226
|
+
next_action=action,
|
|
227
|
+
wake_after_seconds=wake,
|
|
228
|
+
blocking_constraints=blocking_ids,
|
|
229
|
+
)
|
|
230
|
+
journal.updated_at = current_time
|
|
231
|
+
journal.observation = observation
|
|
232
|
+
journal.repair_attempt = repair_attempt
|
|
233
|
+
journal.unchanged_repairs = unchanged_repairs
|
|
234
|
+
journal.prior_state = state
|
|
235
|
+
journal.prior_snapshot = snapshot
|
|
236
|
+
journal.input_snapshot = input_snapshot
|
|
237
|
+
journal.last_result = result.model_dump(mode="json")
|
|
238
|
+
_write_json(path, journal.model_dump(mode="json"))
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@contextmanager
|
|
243
|
+
def loop_lease(
|
|
244
|
+
project_root: Path,
|
|
245
|
+
loop_name: str,
|
|
246
|
+
*,
|
|
247
|
+
ttl_seconds: float,
|
|
248
|
+
now: float | None = None,
|
|
249
|
+
) -> Iterator[Callable[[], None]]:
|
|
250
|
+
"""Acquire a recoverable single-writer supervisor lease."""
|
|
251
|
+
current_time = time.time() if now is None else now
|
|
252
|
+
path = lease_path(project_root, loop_name)
|
|
253
|
+
token = str(uuid.uuid4())
|
|
254
|
+
with _write_lock(path):
|
|
255
|
+
existing = _read_json(path, {})
|
|
256
|
+
if isinstance(existing, dict) and float(existing.get("expires_at", 0)) > current_time:
|
|
257
|
+
raise LoopError(f"Loop {loop_name!r} already has an active supervisor lease")
|
|
258
|
+
_write_json(
|
|
259
|
+
path,
|
|
260
|
+
{
|
|
261
|
+
"schema_version": 1,
|
|
262
|
+
"loop": loop_name,
|
|
263
|
+
"pid": os.getpid(),
|
|
264
|
+
"token": token,
|
|
265
|
+
"expires_at": current_time + ttl_seconds,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def renew() -> None:
|
|
270
|
+
renewed_at = time.time()
|
|
271
|
+
with _write_lock(path):
|
|
272
|
+
existing = _read_json(path, {})
|
|
273
|
+
if not isinstance(existing, dict) or existing.get("token") != token:
|
|
274
|
+
raise LoopError(f"Loop {loop_name!r} supervisor lease was lost")
|
|
275
|
+
existing["expires_at"] = renewed_at + ttl_seconds
|
|
276
|
+
_write_json(path, existing)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
yield renew
|
|
280
|
+
finally:
|
|
281
|
+
with _write_lock(path):
|
|
282
|
+
existing = _read_json(path, {})
|
|
283
|
+
if isinstance(existing, dict) and existing.get("token") == token:
|
|
284
|
+
path.unlink(missing_ok=True)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def supervise(
|
|
288
|
+
project_root: Path,
|
|
289
|
+
contract: Contract,
|
|
290
|
+
loop_name: str,
|
|
291
|
+
) -> Iterator[CycleResult]:
|
|
292
|
+
"""Yield state changes while waiting; return on every non-waiting state."""
|
|
293
|
+
if loop_name not in contract.loops:
|
|
294
|
+
raise LoopError(f"Unknown loop {loop_name!r}")
|
|
295
|
+
config = contract.loops[loop_name]
|
|
296
|
+
ttl = max(60.0, config.interval_seconds * 3)
|
|
297
|
+
cancelled = False
|
|
298
|
+
|
|
299
|
+
def cancel(_signum: int, _frame: Any) -> None:
|
|
300
|
+
nonlocal cancelled
|
|
301
|
+
cancelled = True
|
|
302
|
+
|
|
303
|
+
previous_handlers = {
|
|
304
|
+
signum: signal.signal(signum, cancel) for signum in (signal.SIGINT, signal.SIGTERM)
|
|
305
|
+
}
|
|
306
|
+
try:
|
|
307
|
+
with loop_lease(project_root, loop_name, ttl_seconds=ttl) as renew:
|
|
308
|
+
previous_state: LoopState | None = None
|
|
309
|
+
while not cancelled:
|
|
310
|
+
renew()
|
|
311
|
+
result = run_cycle(project_root, contract, loop_name)
|
|
312
|
+
if result.state != previous_state:
|
|
313
|
+
yield result
|
|
314
|
+
previous_state = result.state
|
|
315
|
+
if result.state != LoopState.WAITING:
|
|
316
|
+
return
|
|
317
|
+
wake_at = time.monotonic() + result.wake_after_seconds
|
|
318
|
+
while not cancelled and time.monotonic() < wake_at:
|
|
319
|
+
time.sleep(min(1.0, wake_at - time.monotonic()))
|
|
320
|
+
finally:
|
|
321
|
+
for signum, handler in previous_handlers.items():
|
|
322
|
+
signal.signal(signum, handler)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def loop_prompt(loop_name: str, adapter: str) -> str:
|
|
326
|
+
if adapter not in {"claude", "codex"}:
|
|
327
|
+
raise LoopError(f"Unsupported loop adapter {adapter!r}")
|
|
328
|
+
return (
|
|
329
|
+
f"Run `constraintloop cycle {loop_name} --json` exactly once. Follow only its "
|
|
330
|
+
"`next_action`. Make at most one repair when state is `repair`; make no edits when "
|
|
331
|
+
"state is `waiting`. Stop on `passed`, `human_required`, `budget_exhausted`, or "
|
|
332
|
+
"`error`. Never edit the ConstraintLoop configuration or create a waiver. Repeat "
|
|
333
|
+
"only after the requested repair or wake interval."
|
|
334
|
+
)
|