regent-cli 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {regent_cli-0.6.0 → regent_cli-0.7.0}/PKG-INFO +1 -1
- {regent_cli-0.6.0 → regent_cli-0.7.0}/pyproject.toml +1 -1
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/__init__.py +1 -1
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/activity_cli.py +54 -0
- regent_cli-0.7.0/src/regent/conduction/abort.py +130 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/gate.py +2 -2
- regent_cli-0.7.0/src/regent/conduction/loop.py +265 -0
- regent_cli-0.7.0/src/regent/conduction/process.py +70 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/turn.py +139 -23
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/templates/MANIFEST.json +2 -1
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent/SKILL.md +6 -2
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/PKG-INFO +1 -1
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/SOURCES.txt +5 -0
- regent_cli-0.7.0/tests/test_abort.py +358 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_activity_cli.py +2 -2
- regent_cli-0.7.0/tests/test_loop.py +175 -0
- regent_cli-0.7.0/tests/test_loop_cli.py +86 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_skills_v1.py +2 -2
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_turn.py +4 -4
- regent_cli-0.6.0/src/regent/conduction/process.py +0 -45
- {regent_cli-0.6.0 → regent_cli-0.7.0}/LICENSE +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/README.md +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/setup.cfg +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/activity.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/cli.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/__init__.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/confine.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/consult.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/evidence.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/hookscript.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/turnlog.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/doctor.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/initcmd.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/__init__.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/audit.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/control.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/lock.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/stop.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/top_level.txt +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_activity.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_choreography.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_confine.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_consult.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_control.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_doctor.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_gate.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_hookscript.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_init_upgrade.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_initcmd.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_lock.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_protocol_facade.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_step06_fixes.py +0 -0
- {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_stop.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Autonomous conduction and mediated adversarial deliberation between AI agents, pluggable into any project."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -28,6 +28,9 @@ _EXIT_BY_CODE = {
|
|
|
28
28
|
"TURN_VIOLATION": 3, "TURN_TAMPERED": 3, "TURN_FAILED": 3, "NOT_ACTIVE": 2,
|
|
29
29
|
"STEP_MISMATCH": 2, "ARTIFACT_OUTSIDE_REGENT": 2, "STEP_ALREADY_DONE": 2,
|
|
30
30
|
"WORKTREE_DIRTY": 3,
|
|
31
|
+
"LOOP_HALTED": 3, "LOOP_ABORTED": 3, "LOOP_MAX_TURNS": 3, "LOOP_STOPPED": 2,
|
|
32
|
+
"LOOP_BUSY": 3, "PLAN_NOT_EXECUTABLE": 2, "LOOP_CONFLICT": 3, "LOOP_DIRTY": 3,
|
|
33
|
+
"LOOP_MISCONFIGURED": 2, "ABORT_PENDING": 3,
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
|
|
@@ -105,6 +108,22 @@ def build_parser(sub) -> None:
|
|
|
105
108
|
p.add_argument("--timeout", type=float, default=600.0)
|
|
106
109
|
p.add_argument("--expect-verdict", default=None, dest="expect_verdict")
|
|
107
110
|
|
|
111
|
+
p_loop = sub.add_parser("loop", help="chain supervised turns over a plan")
|
|
112
|
+
p_loop.add_argument("--project", default=None)
|
|
113
|
+
loop_sub = p_loop.add_subparsers(dest="loop_command", required=True)
|
|
114
|
+
p = loop_sub.add_parser("run")
|
|
115
|
+
p.add_argument("--plan", required=True)
|
|
116
|
+
p.add_argument("--prompt-template", required=True, dest="prompt_template")
|
|
117
|
+
p.add_argument("--envelope", action="append", required=True)
|
|
118
|
+
p.add_argument("--gate-envelope", action="append", default=[], dest="gate_envelope")
|
|
119
|
+
p.add_argument("--declared-in", required=True, dest="declared_in")
|
|
120
|
+
p.add_argument("--artifact-dir", required=True, dest="artifact_dir")
|
|
121
|
+
p.add_argument("--max-turns", type=int, default=20, dest="max_turns")
|
|
122
|
+
p.add_argument("--timeout", type=float, default=900.0)
|
|
123
|
+
p.add_argument("--claude-bin", default="claude", dest="claude_bin")
|
|
124
|
+
p = loop_sub.add_parser("abort")
|
|
125
|
+
p.add_argument("--reason", required=True)
|
|
126
|
+
|
|
108
127
|
p_turn = sub.add_parser("turn", help="supervised confined production turn")
|
|
109
128
|
p_turn.add_argument("--project", default=None)
|
|
110
129
|
turn_sub = p_turn.add_subparsers(dest="turn_command", required=True)
|
|
@@ -179,6 +198,41 @@ def run(args, out=None) -> int:
|
|
|
179
198
|
"verdict": result["verdict"],
|
|
180
199
|
"artifact": result["artifact"]}, out)
|
|
181
200
|
return _emit(result, 0, out)
|
|
201
|
+
if args.command == "loop":
|
|
202
|
+
if args.loop_command == "abort":
|
|
203
|
+
from .conduction.abort import AbortPending, write_abort_request
|
|
204
|
+
control = service.store.load()
|
|
205
|
+
activity = control.get("activity")
|
|
206
|
+
if activity is None:
|
|
207
|
+
return _fail("NO_ACTIVITY", {"state": "idle"}, out)
|
|
208
|
+
token = (activity.get("turn") or {}).get("token")
|
|
209
|
+
try:
|
|
210
|
+
req = write_abort_request(
|
|
211
|
+
service.state_dir, activity_id=activity["id"],
|
|
212
|
+
activity_epoch=activity["epoch"], turn_token=token,
|
|
213
|
+
reason=args.reason)
|
|
214
|
+
except AbortPending:
|
|
215
|
+
return _fail("ABORT_PENDING", {"reason": "an abort is already "
|
|
216
|
+
"pending"}, out)
|
|
217
|
+
return _emit({"ok": True, "abort_id": req["id"]}, 0, out)
|
|
218
|
+
from .conduction.loop import LoopError, run_loop
|
|
219
|
+
try:
|
|
220
|
+
result = run_loop(
|
|
221
|
+
root, plan_id=args.plan, prompt_template=Path(args.prompt_template),
|
|
222
|
+
envelope=args.envelope, gate_envelope=args.gate_envelope,
|
|
223
|
+
declared_in=Path(args.declared_in),
|
|
224
|
+
artifact_dir=Path(args.artifact_dir), max_turns=args.max_turns,
|
|
225
|
+
timeout=args.timeout, claude_bin=args.claude_bin)
|
|
226
|
+
except LoopError as exc:
|
|
227
|
+
return _fail(exc.code, exc.detail, out)
|
|
228
|
+
if not result["ok"]:
|
|
229
|
+
cond = result["stop_condition"]
|
|
230
|
+
code = {"HALTED": "LOOP_HALTED", "ABORTED": "LOOP_ABORTED",
|
|
231
|
+
"MAX_TURNS": "LOOP_MAX_TURNS", "STOPPED": "LOOP_STOPPED",
|
|
232
|
+
}.get(cond, cond if cond in _EXIT_BY_CODE else "LOOP_HALTED")
|
|
233
|
+
return _fail(code, {"stop_condition": cond, "turns": result["turns"],
|
|
234
|
+
"count": result["count"]}, out)
|
|
235
|
+
return _emit(result, 0, out)
|
|
182
236
|
if args.command == "turn":
|
|
183
237
|
from .conduction.turn import TurnError, run_turn
|
|
184
238
|
from .conduction.gate import ProvenanceError
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Durable abort-request signalling (PLAN-005 STEP-01).
|
|
2
|
+
|
|
3
|
+
A `regent loop abort` writes an abort-request into the DISPOSABLE XDG state
|
|
4
|
+
(outside the repo), bound to {activity_id, activity_epoch, turn_token} plus the
|
|
5
|
+
in-flight turn nonce. The in-flight turn's keep-alive reads it (~1s), validates
|
|
6
|
+
the binding, and — only if a matching turn is in flight — signals cancellation.
|
|
7
|
+
Honored exactly once (rename to `.claimed`); a request whose binding no longer
|
|
8
|
+
matches is discarded with an audit record. Creation is O_EXCL: one pending
|
|
9
|
+
abort at a time.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..protocol.audit import AuditLog, utcnow
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AbortPending(Exception):
|
|
23
|
+
"""An abort-request is already pending (O_EXCL)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def request_path(state_dir: Path) -> Path:
|
|
27
|
+
return Path(state_dir) / "abort.request"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_abort_request(state_dir: Path, *, activity_id: str, activity_epoch: int,
|
|
31
|
+
turn_token: str | None, reason: str) -> dict:
|
|
32
|
+
state_dir = Path(state_dir)
|
|
33
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
payload = {"id": uuid.uuid4().hex, "activity_id": activity_id,
|
|
35
|
+
"activity_epoch": activity_epoch, "turn_token": turn_token,
|
|
36
|
+
"turn_nonce": _turn_in_flight(state_dir), # bind to the CURRENT turn
|
|
37
|
+
"reason": reason, "requested_at": utcnow()}
|
|
38
|
+
path = request_path(state_dir)
|
|
39
|
+
try:
|
|
40
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
41
|
+
except FileExistsError:
|
|
42
|
+
raise AbortPending(str(path)) from None
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
44
|
+
json.dump(payload, handle)
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write_turn_nonce(state_dir: Path, nonce: str) -> None:
|
|
49
|
+
"""Marks a turn in flight (atomic tmp+replace)."""
|
|
50
|
+
target = Path(state_dir) / "turn.nonce"
|
|
51
|
+
tmp = target.with_name(".turn.nonce.tmp")
|
|
52
|
+
tmp.write_text(nonce, encoding="utf-8")
|
|
53
|
+
os.replace(tmp, target)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def clear_turn_nonce(state_dir: Path) -> None:
|
|
57
|
+
try:
|
|
58
|
+
(Path(state_dir) / "turn.nonce").unlink()
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _turn_in_flight(state_dir: Path) -> str | None:
|
|
64
|
+
try:
|
|
65
|
+
return (Path(state_dir) / "turn.nonce").read_text(encoding="utf-8").strip()
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def claim_matching_abort(state_dir: Path, audit: AuditLog, *, activity_id: str,
|
|
71
|
+
activity_epoch: int, turn_token: str | None) -> dict | None:
|
|
72
|
+
"""Returns and CLAIMS (rename → .claimed) a valid abort-request bound to the
|
|
73
|
+
current activity while a turn is in flight; discards a stale one (audited);
|
|
74
|
+
returns None if there is none/not honorable."""
|
|
75
|
+
path = request_path(state_dir)
|
|
76
|
+
try:
|
|
77
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
in_flight = _turn_in_flight(state_dir)
|
|
81
|
+
bound = (payload.get("activity_id") == activity_id
|
|
82
|
+
and payload.get("activity_epoch") == activity_epoch
|
|
83
|
+
and payload.get("turn_token") == turn_token
|
|
84
|
+
and payload.get("turn_nonce") is not None
|
|
85
|
+
and payload.get("turn_nonce") == in_flight) # SAME turn, not just any
|
|
86
|
+
if not bound:
|
|
87
|
+
_discard(path, audit, payload, "binding mismatch or wrong/absent turn nonce")
|
|
88
|
+
return None
|
|
89
|
+
claimed = path.with_name(f"abort.claimed-{payload['id']}") # unique, never clobbered
|
|
90
|
+
try:
|
|
91
|
+
os.replace(path, claimed) # single claim
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pending_claimed(state_dir: Path) -> list[Path]:
|
|
98
|
+
"""Unfinished aborts (claimed but not yet reconciled) — for recovery."""
|
|
99
|
+
return sorted(Path(state_dir).glob("abort.claimed-*"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def clear_claimed(state_dir: Path, *, activity_id: str | None = None,
|
|
103
|
+
activity_epoch: int | None = None,
|
|
104
|
+
turn_token: str | None = None) -> None:
|
|
105
|
+
"""Removes claimed markers. If a binding is given, ONLY markers matching it
|
|
106
|
+
(never unrelated aborts)."""
|
|
107
|
+
import json as _json
|
|
108
|
+
for p in pending_claimed(state_dir):
|
|
109
|
+
if activity_id is not None:
|
|
110
|
+
try:
|
|
111
|
+
m = _json.loads(p.read_text(encoding="utf-8"))
|
|
112
|
+
except (OSError, ValueError):
|
|
113
|
+
continue
|
|
114
|
+
if not (m.get("activity_id") == activity_id
|
|
115
|
+
and m.get("activity_epoch") == activity_epoch
|
|
116
|
+
and m.get("turn_token") == turn_token):
|
|
117
|
+
continue # a different abort — leave it
|
|
118
|
+
try:
|
|
119
|
+
p.unlink()
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _discard(path: Path, audit: AuditLog, payload: dict, reason: str) -> None:
|
|
125
|
+
audit.append({"event": "abort_request_discarded", "request_id": payload.get("id"),
|
|
126
|
+
"reason": reason})
|
|
127
|
+
try:
|
|
128
|
+
path.unlink()
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
@@ -22,7 +22,7 @@ class ProvenanceError(Exception):
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def run_gate(root: Path, *, command: str, declared_in: Path, artifact: Path,
|
|
25
|
-
linkage: str, timeout: float = 1800.0, runner=None) -> dict:
|
|
25
|
+
linkage: str, timeout: float = 1800.0, runner=None, cancel=None) -> dict:
|
|
26
26
|
root = Path(root)
|
|
27
27
|
if not command.strip():
|
|
28
28
|
raise ProvenanceError("empty gate command is never declared")
|
|
@@ -41,7 +41,7 @@ def run_gate(root: Path, *, command: str, declared_in: Path, artifact: Path,
|
|
|
41
41
|
# ONE cleanup guard around execution + both publishes (see consult.py).
|
|
42
42
|
try:
|
|
43
43
|
result = runner.run(["bash", "-c", command], cwd=str(root),
|
|
44
|
-
timeout=timeout)
|
|
44
|
+
timeout=timeout, cancel=cancel)
|
|
45
45
|
raw = result.output_bytes
|
|
46
46
|
output_bytes = len(raw)
|
|
47
47
|
truncated = output_bytes > TAIL_LIMIT
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""`regent loop run` — chains supervised turns over an approved build plan
|
|
2
|
+
(PLAN-005 STEP-02). Deterministic driver: recompute the current step from disk,
|
|
3
|
+
run one confined turn, map the outcome to a terminal condition. No own state
|
|
4
|
+
(disk is the truth); no auto-retry; a mid-agent turn is never resumed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..activity import ActivityService
|
|
14
|
+
from ..protocol.control import _FlockMutex, MutationMutexBusy
|
|
15
|
+
from .evidence import EvidenceSet, EvidenceConflict as _EvidenceConflict, header
|
|
16
|
+
from .turn import TurnError, run_turn, _git, _slug
|
|
17
|
+
|
|
18
|
+
# Outcome → loop stop condition (the complete map, PLAN-005 v3 §6).
|
|
19
|
+
_HALT_OUTCOMES = {"GATE_RED", "TURN_VIOLATION", "TURN_TAMPERED", "FAILURE", "TIMEOUT"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LoopError(Exception):
|
|
23
|
+
def __init__(self, code: str, detail: dict) -> None:
|
|
24
|
+
super().__init__(code)
|
|
25
|
+
self.code, self.detail = code, detail
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _committed_steps(root: Path, plan_id: str) -> set[str]:
|
|
29
|
+
"""Steps proven done: a commit reachable from HEAD whose message has the
|
|
30
|
+
exact `Regent-Step: PLAN-NNN/STEP-NN` trailer AND that touched
|
|
31
|
+
build/STEP-NN.md AND the file exists in HEAD."""
|
|
32
|
+
done = set()
|
|
33
|
+
build_rel = f".regent/plans/{plan_id}/build"
|
|
34
|
+
out = _git(root, "log", "--format=%H", f"--grep=^Regent-Step: {re.escape(plan_id)}/",
|
|
35
|
+
"-E")
|
|
36
|
+
for sha in out.split():
|
|
37
|
+
msg = _git(root, "log", "-1", "--format=%B", sha)
|
|
38
|
+
for m in re.finditer(rf"^Regent-Step: {re.escape(plan_id)}/(STEP-\d+)$",
|
|
39
|
+
msg, re.M):
|
|
40
|
+
step = m.group(1)
|
|
41
|
+
step_path = f"{build_rel}/{step}.md"
|
|
42
|
+
touched = _git(root, "show", "--name-only", "--format=", sha)
|
|
43
|
+
if step_path in touched.split() and _file_in_head(root, step_path):
|
|
44
|
+
done.add(step)
|
|
45
|
+
return done
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _file_in_head(root: Path, rel: str) -> bool:
|
|
49
|
+
r = subprocess.run(["git", "-C", str(root), "cat-file", "-e", f"HEAD:{rel}"],
|
|
50
|
+
capture_output=True)
|
|
51
|
+
return r.returncode == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _declared_steps(plan_text: str) -> list[str]:
|
|
55
|
+
return sorted(set(re.findall(r"STEP-\d+", plan_text)),
|
|
56
|
+
key=lambda s: int(s.split("-")[1]))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _step_gate(plan_text: str, step_name: str) -> str | None:
|
|
60
|
+
# Boundary = next step HEADING line, so a gate command containing a step
|
|
61
|
+
# name (e.g. a path) is not truncated (matches turn._step_gate).
|
|
62
|
+
m = re.search(rf"{re.escape(step_name)}\b(.*?)(?=\n#+[^\n]*STEP-\d+|\Z)",
|
|
63
|
+
plan_text, re.S)
|
|
64
|
+
if not m:
|
|
65
|
+
return None
|
|
66
|
+
g = re.search(r"\*\*Gate:\*\*\s*`([^`]+)`", m.group(1))
|
|
67
|
+
return g.group(1) if g else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _attempt_number(artifact_dir: Path, step_name: str) -> int:
|
|
71
|
+
"""K = max existing tryN for this step + 1 (not a count)."""
|
|
72
|
+
existing = [int(m.group(1)) for p in artifact_dir.glob(f"TURN-*{step_name}*try*.md")
|
|
73
|
+
for m in [re.search(r"try(\d+)", p.name)] if m]
|
|
74
|
+
return (max(existing) + 1) if existing else 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _approval_status(root: Path, plan_id: str) -> str | None:
|
|
78
|
+
path = root / ".regent" / "plans" / plan_id / "APPROVAL.md"
|
|
79
|
+
try:
|
|
80
|
+
m = re.search(r"^status:\s*(\S+)", path.read_text(encoding="utf-8"), re.M)
|
|
81
|
+
return m.group(1) if m else None
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_loop(root: Path, *, plan_id: str, prompt_template: Path, envelope: list[str],
|
|
87
|
+
gate_envelope: list[str] | None, declared_in: Path, artifact_dir: Path,
|
|
88
|
+
max_turns: int = 20, timeout: float = 900.0, claude_bin: str = "claude",
|
|
89
|
+
runner=None, service: ActivityService | None = None) -> dict:
|
|
90
|
+
root = Path(root).resolve()
|
|
91
|
+
service = service or ActivityService(root)
|
|
92
|
+
artifact_dir = (root / artifact_dir).resolve() if not Path(artifact_dir).is_absolute() \
|
|
93
|
+
else Path(artifact_dir).resolve()
|
|
94
|
+
if max_turns < 1:
|
|
95
|
+
raise LoopError("USAGE", {"reason": "--max-turns must be >= 1"})
|
|
96
|
+
template = Path(prompt_template).read_text(encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
loop_lock = _FlockMutex(service.state_dir / "loop.lock", timeout=0.5)
|
|
99
|
+
try:
|
|
100
|
+
loop_lock.__enter__()
|
|
101
|
+
except MutationMutexBusy:
|
|
102
|
+
raise LoopError("LOOP_BUSY", {"reason": "another loop run holds the loop lock"})
|
|
103
|
+
turns: list[dict] = []
|
|
104
|
+
condition = "COMPLETE"
|
|
105
|
+
try:
|
|
106
|
+
try:
|
|
107
|
+
while True:
|
|
108
|
+
control = service.store.load()
|
|
109
|
+
activity = control.get("activity")
|
|
110
|
+
if activity is None or activity["state"] != "ACTIVE" \
|
|
111
|
+
or activity["type"] != "build" or activity["id"] != plan_id:
|
|
112
|
+
condition = "PLAN_NOT_EXECUTABLE"
|
|
113
|
+
break
|
|
114
|
+
if _approval_status(root, plan_id) != "APPROVED":
|
|
115
|
+
condition = "PLAN_NOT_EXECUTABLE"
|
|
116
|
+
break
|
|
117
|
+
plan_text = Path(declared_in).read_text(encoding="utf-8", errors="replace")
|
|
118
|
+
declared = _declared_steps(plan_text)
|
|
119
|
+
done = _committed_steps(root, plan_id)
|
|
120
|
+
remaining = [s for s in declared if s not in done]
|
|
121
|
+
if not remaining:
|
|
122
|
+
condition = "COMPLETE"
|
|
123
|
+
break
|
|
124
|
+
if len(turns) >= max_turns:
|
|
125
|
+
condition = "MAX_TURNS"
|
|
126
|
+
break
|
|
127
|
+
step = remaining[0]
|
|
128
|
+
gate = _step_gate(plan_text, step)
|
|
129
|
+
attempt = _attempt_number(artifact_dir, step)
|
|
130
|
+
prompt = template.replace("{step}", step).replace("{gate}", gate or "") \
|
|
131
|
+
.replace("{plan}", plan_id)
|
|
132
|
+
# write the prompt OUTSIDE the repo (state dir) — never dirty the
|
|
133
|
+
# worktree that run_turn requires clean.
|
|
134
|
+
prompt_file = service.state_dir / f"prompt-{step}-try{attempt}.txt"
|
|
135
|
+
prompt_file.write_text(prompt, encoding="utf-8")
|
|
136
|
+
try:
|
|
137
|
+
result = run_turn(
|
|
138
|
+
root, prompt_file=prompt_file, envelope=envelope,
|
|
139
|
+
gate_command=gate, declared_in=declared_in, step=f"{plan_id}/{step}",
|
|
140
|
+
artifact_dir=artifact_dir, linkage=f"{plan_id}/{step}/try{attempt}",
|
|
141
|
+
gate_envelope=gate_envelope, timeout=timeout, claude_bin=claude_bin,
|
|
142
|
+
runner=runner, service=service, attempt=attempt)
|
|
143
|
+
except TurnError as exc:
|
|
144
|
+
condition = _EXC_TO_CONDITION.get(exc.code, "LOOP_CONFLICT")
|
|
145
|
+
turns.append({"step": step, "attempt": attempt, "outcome": exc.code,
|
|
146
|
+
"commit": None})
|
|
147
|
+
break
|
|
148
|
+
except _EvidenceConflict:
|
|
149
|
+
condition = "LOOP_CONFLICT"
|
|
150
|
+
turns.append({"step": step, "attempt": attempt,
|
|
151
|
+
"outcome": "EVIDENCE_CONFLICT", "commit": None})
|
|
152
|
+
break
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
# spawn / IO failure of the turn itself → treat as a failed turn
|
|
155
|
+
condition = "HALTED"
|
|
156
|
+
turns.append({"step": step, "attempt": attempt,
|
|
157
|
+
"outcome": "FAILURE",
|
|
158
|
+
"detail": f"{type(exc).__name__}: {exc}", "commit": None})
|
|
159
|
+
break
|
|
160
|
+
except subprocess.CalledProcessError as exc:
|
|
161
|
+
condition = "LOOP_CONFLICT"
|
|
162
|
+
turns.append({"step": step, "attempt": attempt,
|
|
163
|
+
"outcome": f"GIT_ERROR:{exc.returncode}", "commit": None})
|
|
164
|
+
break
|
|
165
|
+
finally:
|
|
166
|
+
try:
|
|
167
|
+
prompt_file.unlink()
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
turns.append({"step": step, "attempt": attempt,
|
|
171
|
+
"outcome": result["outcome"], "commit": result.get("commit")})
|
|
172
|
+
outcome = result["outcome"]
|
|
173
|
+
if outcome == "TURN_OK":
|
|
174
|
+
continue # advance; recompute from disk
|
|
175
|
+
if outcome == "STOPPED":
|
|
176
|
+
condition = "STOPPED"
|
|
177
|
+
break
|
|
178
|
+
if outcome == "ABORTED":
|
|
179
|
+
condition = "ABORTED"
|
|
180
|
+
break
|
|
181
|
+
if outcome in _HALT_OUTCOMES:
|
|
182
|
+
condition = "HALTED"
|
|
183
|
+
break
|
|
184
|
+
condition = "LOOP_CONFLICT"
|
|
185
|
+
break
|
|
186
|
+
except subprocess.CalledProcessError:
|
|
187
|
+
condition = "LOOP_CONFLICT"
|
|
188
|
+
try:
|
|
189
|
+
summary_conflict = _write_loop_evidence(root, artifact_dir, service,
|
|
190
|
+
condition, turns)
|
|
191
|
+
if summary_conflict:
|
|
192
|
+
condition = "LOOP_CONFLICT" # summary could not commit (any prior cond)
|
|
193
|
+
except subprocess.CalledProcessError:
|
|
194
|
+
condition = "LOOP_CONFLICT" # any git error in the summary is a conflict
|
|
195
|
+
finally:
|
|
196
|
+
loop_lock.__exit__(None, None, None)
|
|
197
|
+
|
|
198
|
+
ok = condition == "COMPLETE"
|
|
199
|
+
return {"ok": ok, "stop_condition": condition, "turns": turns, "count": len(turns)}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
_EXC_TO_CONDITION = {
|
|
203
|
+
"NOT_ACTIVE": "PLAN_NOT_EXECUTABLE", "CONFLICT": "LOOP_CONFLICT",
|
|
204
|
+
"WORKTREE_DIRTY": "LOOP_DIRTY", "PROVENANCE": "LOOP_MISCONFIGURED",
|
|
205
|
+
"STEP_MISMATCH": "LOOP_MISCONFIGURED", "ARTIFACT_OUTSIDE_REGENT": "LOOP_MISCONFIGURED",
|
|
206
|
+
"STEP_ALREADY_DONE": "LOOP_MISCONFIGURED", "STOPPED": "STOPPED",
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _write_loop_evidence(root: Path, artifact_dir: Path, service: ActivityService,
|
|
211
|
+
condition: str, turns: list[dict]) -> None:
|
|
212
|
+
slug = _slug(f"{condition}-{len(turns)}")
|
|
213
|
+
artifact = artifact_dir / f"LOOP-{slug}.md"
|
|
214
|
+
evidence = EvidenceSet(artifact, {})
|
|
215
|
+
try:
|
|
216
|
+
evidence.precheck()
|
|
217
|
+
except _EvidenceConflict: # never block on evidence naming
|
|
218
|
+
artifact = artifact_dir / f"LOOP-{slug}-{len(list(artifact_dir.glob('LOOP-*')))}.md"
|
|
219
|
+
evidence = EvidenceSet(artifact, {})
|
|
220
|
+
body = "\n".join(
|
|
221
|
+
f"- {t['step']} try{t['attempt']} → {t['outcome']}"
|
|
222
|
+
+ (f" ({t['commit'][:8]})" if t.get("commit") else "") for t in turns)
|
|
223
|
+
evidence.write_main(header(condition, None, "loop", turns=len(turns)),
|
|
224
|
+
body or "(no turns)")
|
|
225
|
+
# Summary commit through a PRIVATE index + HEAD CAS (never the shared index;
|
|
226
|
+
# no accidentally-staged files). Fenced when a token exists (ACTIVE); a plain
|
|
227
|
+
# CAS commit when SUSPENDED (no token) — both under the loop lock still held.
|
|
228
|
+
import os as _os
|
|
229
|
+
import tempfile as _tf
|
|
230
|
+
from ..protocol.control import assert_turn_token, NotLockOwner
|
|
231
|
+
control = service.store.load()
|
|
232
|
+
activity = control.get("activity")
|
|
233
|
+
token = (activity.get("turn") or {}).get("token") if activity else None
|
|
234
|
+
rel = str(artifact.relative_to(root))
|
|
235
|
+
base_sha = _git(root, "rev-parse", "HEAD").strip()
|
|
236
|
+
index = Path(_tf.mktemp(prefix="regent-loop-index-"))
|
|
237
|
+
env = dict(_os.environ, GIT_INDEX_FILE=str(index))
|
|
238
|
+
try:
|
|
239
|
+
_git(root, "read-tree", "HEAD", env=env)
|
|
240
|
+
_git(root, "add", "--", rel, env=env)
|
|
241
|
+
base_tree = _git(root, "rev-parse", "HEAD^{tree}").strip()
|
|
242
|
+
tree = _git(root, "write-tree", env=env).strip()
|
|
243
|
+
if tree == base_tree:
|
|
244
|
+
return False
|
|
245
|
+
msg = (f"loop(PLAN-005): {condition} summary ({len(turns)} turns)\n\n"
|
|
246
|
+
f"Regent-Loop: {condition}")
|
|
247
|
+
commit = _git(root, "commit-tree", tree, "-p", base_sha, "-m", msg,
|
|
248
|
+
env=env).strip()
|
|
249
|
+
# Fencing + CAS in the SMALLEST window right before advancing HEAD.
|
|
250
|
+
if token is not None:
|
|
251
|
+
try:
|
|
252
|
+
assert_turn_token(service.store.load(), token)
|
|
253
|
+
except NotLockOwner:
|
|
254
|
+
return True # taken over — summary NOT committed (conflict)
|
|
255
|
+
if _git(root, "rev-parse", "HEAD").strip() != base_sha:
|
|
256
|
+
return True # HEAD moved (raced with resume) — conflict
|
|
257
|
+
_git(root, "update-ref", "-m", "regent loop summary", "HEAD", commit, base_sha,
|
|
258
|
+
env=env)
|
|
259
|
+
_git(root, "reset", "--mixed", "HEAD")
|
|
260
|
+
return False
|
|
261
|
+
finally:
|
|
262
|
+
try:
|
|
263
|
+
index.unlink()
|
|
264
|
+
except OSError:
|
|
265
|
+
pass
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Injectable process runner for the conduction commands (PLAN-003).
|
|
2
|
+
|
|
3
|
+
The real runner launches the child in a NEW SESSION (its own process group);
|
|
4
|
+
on timeout the WHOLE group is SIGKILLed before the TIMEOUT outcome is
|
|
5
|
+
reported — no child (not even ones spawned by bash) survives the result.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RunResult:
|
|
18
|
+
exit_code: int | None
|
|
19
|
+
output_bytes: bytes
|
|
20
|
+
timed_out: bool
|
|
21
|
+
aborted: bool = False
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def output(self) -> str:
|
|
25
|
+
return self.output_bytes.decode("utf-8", errors="replace")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SubprocessRunner:
|
|
29
|
+
def run(self, argv: list[str], *, cwd: str, timeout: float,
|
|
30
|
+
env: dict | None = None, cancel=None) -> RunResult:
|
|
31
|
+
"""Cancellable + deadlock-free: a reader THREAD drains stdout while the
|
|
32
|
+
main loop polls `cancel` (checked BEFORE timeout — deterministic
|
|
33
|
+
precedence) and the deadline. On either, the whole group is SIGKILLed
|
|
34
|
+
and the group reaped. `aborted` (cancel) is distinct from `timed_out`."""
|
|
35
|
+
import threading
|
|
36
|
+
import time as _time
|
|
37
|
+
proc = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL,
|
|
38
|
+
stdout=subprocess.PIPE,
|
|
39
|
+
stderr=subprocess.STDOUT, env=env,
|
|
40
|
+
start_new_session=True)
|
|
41
|
+
chunks: list[bytes] = []
|
|
42
|
+
reader = threading.Thread(target=lambda: chunks.append(proc.stdout.read()),
|
|
43
|
+
daemon=True)
|
|
44
|
+
reader.start()
|
|
45
|
+
deadline = _time.monotonic() + timeout
|
|
46
|
+
aborted = timed_out = False
|
|
47
|
+
while True:
|
|
48
|
+
if cancel is not None and cancel.is_set(): # cancel BEFORE timeout
|
|
49
|
+
aborted = True
|
|
50
|
+
break
|
|
51
|
+
if _time.monotonic() >= deadline:
|
|
52
|
+
timed_out = True
|
|
53
|
+
break
|
|
54
|
+
if proc.poll() is not None:
|
|
55
|
+
break
|
|
56
|
+
_time.sleep(0.1)
|
|
57
|
+
if aborted or timed_out:
|
|
58
|
+
try:
|
|
59
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
60
|
+
except (ProcessLookupError, PermissionError):
|
|
61
|
+
pass
|
|
62
|
+
proc.wait() # reap the group leader
|
|
63
|
+
reader.join(timeout=5)
|
|
64
|
+
try:
|
|
65
|
+
proc.stdout.close()
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
output = chunks[0] if chunks else b""
|
|
69
|
+
exit_code = None if (aborted or timed_out) else proc.returncode
|
|
70
|
+
return RunResult(exit_code, output or b"", timed_out, aborted)
|