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.
Files changed (56) hide show
  1. {regent_cli-0.6.0 → regent_cli-0.7.0}/PKG-INFO +1 -1
  2. {regent_cli-0.6.0 → regent_cli-0.7.0}/pyproject.toml +1 -1
  3. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/__init__.py +1 -1
  4. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/activity_cli.py +54 -0
  5. regent_cli-0.7.0/src/regent/conduction/abort.py +130 -0
  6. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/gate.py +2 -2
  7. regent_cli-0.7.0/src/regent/conduction/loop.py +265 -0
  8. regent_cli-0.7.0/src/regent/conduction/process.py +70 -0
  9. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/turn.py +139 -23
  10. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/templates/MANIFEST.json +2 -1
  11. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent/SKILL.md +6 -2
  12. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/PKG-INFO +1 -1
  13. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/SOURCES.txt +5 -0
  14. regent_cli-0.7.0/tests/test_abort.py +358 -0
  15. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_activity_cli.py +2 -2
  16. regent_cli-0.7.0/tests/test_loop.py +175 -0
  17. regent_cli-0.7.0/tests/test_loop_cli.py +86 -0
  18. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_skills_v1.py +2 -2
  19. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_turn.py +4 -4
  20. regent_cli-0.6.0/src/regent/conduction/process.py +0 -45
  21. {regent_cli-0.6.0 → regent_cli-0.7.0}/LICENSE +0 -0
  22. {regent_cli-0.6.0 → regent_cli-0.7.0}/README.md +0 -0
  23. {regent_cli-0.6.0 → regent_cli-0.7.0}/setup.cfg +0 -0
  24. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/activity.py +0 -0
  25. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/cli.py +0 -0
  26. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/__init__.py +0 -0
  27. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/confine.py +0 -0
  28. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/consult.py +0 -0
  29. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/evidence.py +0 -0
  30. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/hookscript.py +0 -0
  31. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/conduction/turnlog.py +0 -0
  32. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/doctor.py +0 -0
  33. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/initcmd.py +0 -0
  34. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/__init__.py +0 -0
  35. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/audit.py +0 -0
  36. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/control.py +0 -0
  37. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/lock.py +0 -0
  38. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/protocol/stop.py +0 -0
  39. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent/templates/skills/regent-stop/SKILL.md +0 -0
  40. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/dependency_links.txt +0 -0
  41. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/entry_points.txt +0 -0
  42. {regent_cli-0.6.0 → regent_cli-0.7.0}/src/regent_cli.egg-info/top_level.txt +0 -0
  43. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_activity.py +0 -0
  44. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_choreography.py +0 -0
  45. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_confine.py +0 -0
  46. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_consult.py +0 -0
  47. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_control.py +0 -0
  48. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_doctor.py +0 -0
  49. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_gate.py +0 -0
  50. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_hookscript.py +0 -0
  51. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_init_upgrade.py +0 -0
  52. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_initcmd.py +0 -0
  53. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_lock.py +0 -0
  54. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_protocol_facade.py +0 -0
  55. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_step06_fixes.py +0 -0
  56. {regent_cli-0.6.0 → regent_cli-0.7.0}/tests/test_stop.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regent-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Autonomous conduction and mediated adversarial deliberation between AI agents, pluggable into any project.
5
5
  Author-email: Flavio Alvim <flavioalvim@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "regent-cli"
7
- version = "0.6.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"
@@ -1,3 +1,3 @@
1
1
  """regent — autonomous conduction and mediated adversarial deliberation between AI agents."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -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)