do-again 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.
do_again/__init__.py ADDED
File without changes
do_again/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .platforms.detect import detect_platform
10
+
11
+
12
+ def doctor() -> int:
13
+ info = detect_platform()
14
+ checks = {
15
+ "python": sys.version_info >= (3, 11),
16
+ "git": shutil.which("git") is not None,
17
+ "platform": info.supported,
18
+ }
19
+ for key, ok in checks.items():
20
+ print(f"{'OK' if ok else 'FAIL'} {key}")
21
+ print(f"platform_name={info.name}")
22
+ print(f"service_manager={info.service_manager}")
23
+ return 0 if all(checks.values()) else 1
24
+
25
+
26
+ def status() -> int:
27
+ info = detect_platform()
28
+ print(f"platform={info.name}")
29
+ print(f"service_manager={info.service_manager}")
30
+ return 0
31
+
32
+
33
+ def init_project(path: str) -> int:
34
+ target = Path(path).expanduser().resolve()
35
+ target.mkdir(parents=True, exist_ok=True)
36
+ config = target / "do-again.toml"
37
+ if config.exists():
38
+ print(str(config))
39
+ return 0
40
+ config.write_text('[do_again]\ncontrol_branch = "operator-control"\n')
41
+ print(str(config))
42
+ return 0
43
+
44
+
45
+ def main() -> int:
46
+ parser = argparse.ArgumentParser(prog="do-again")
47
+ sub = parser.add_subparsers(dest="command", required=True)
48
+ sub.add_parser("doctor")
49
+ sub.add_parser("status")
50
+ init_parser = sub.add_parser("init")
51
+ init_parser.add_argument("path", nargs="?", default=".")
52
+ args = parser.parse_args()
53
+ if args.command == "doctor":
54
+ return doctor()
55
+ if args.command == "status":
56
+ return status()
57
+ if args.command == "init":
58
+ return init_project(args.path)
59
+ return 2
60
+
61
+
62
+ if __name__ == "__main__":
63
+ raise SystemExit(main())
File without changes
do_again/core/agent.py ADDED
@@ -0,0 +1,402 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import signal
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import traceback
12
+ from datetime import timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .executor import LocalExecutor
17
+ from .schema import (
18
+ OperatorError,
19
+ atomic_json,
20
+ read_json,
21
+ request_fingerprint,
22
+ utc_now,
23
+ validate_request,
24
+ )
25
+
26
+
27
+ class Agent:
28
+ def __init__(
29
+ self,
30
+ *,
31
+ repo: Path,
32
+ control_worktree: Path,
33
+ branch: str,
34
+ policy_path: Path,
35
+ state_dir: Path,
36
+ ):
37
+ self.repo = repo.resolve()
38
+ self.control_worktree = control_worktree.resolve()
39
+ self.branch = branch
40
+ self.policy_path = policy_path.resolve()
41
+ self.state_dir = state_dir.resolve()
42
+ self.policy = read_json(self.policy_path)
43
+ self.executor = LocalExecutor(
44
+ repo=self.repo,
45
+ policy_path=self.policy_path,
46
+ state_dir=self.state_dir,
47
+ )
48
+ self.requests_dir = (
49
+ self.control_worktree / "automation/do_again/requests"
50
+ )
51
+ self.receipts_dir = (
52
+ self.control_worktree / "automation/do_again/receipts"
53
+ )
54
+ self.ledger_dir = self.state_dir / "ledger"
55
+ self.stop_requested = False
56
+ self.started = utc_now()
57
+ self.instance_id = f"{socket.gethostname()}:{os.getpid()}:{int(self.started.timestamp())}"
58
+ self.poll_seconds = max(1.0, float(self.policy.get("poll_seconds", 3)))
59
+
60
+ def git(self, *args: str, timeout: float = 60) -> subprocess.CompletedProcess[str]:
61
+ return subprocess.run(
62
+ ["git", "-C", str(self.control_worktree), *args],
63
+ text=True,
64
+ capture_output=True,
65
+ timeout=timeout,
66
+ )
67
+
68
+ def require_git(self, *args: str, timeout: float = 60) -> str:
69
+ proc = self.git(*args, timeout=timeout)
70
+ if proc.returncode != 0:
71
+ raise OperatorError(
72
+ f"git {' '.join(args)} failed rc={proc.returncode}: "
73
+ f"{(proc.stderr or proc.stdout).strip()}"
74
+ )
75
+ return proc.stdout
76
+
77
+ def sync(self) -> None:
78
+ status = self.require_git("status", "--porcelain")
79
+ if status.strip():
80
+ raise OperatorError("operator control worktree is dirty")
81
+ self.require_git("fetch", "--quiet", "origin", self.branch)
82
+ self.require_git("rebase", f"origin/{self.branch}")
83
+ push = self.git("push", "origin", f"HEAD:{self.branch}", timeout=90)
84
+ if push.returncode != 0:
85
+ self.require_git("fetch", "--quiet", "origin", self.branch)
86
+ self.require_git("rebase", f"origin/{self.branch}")
87
+ self.require_git("push", "origin", f"HEAD:{self.branch}", timeout=90)
88
+
89
+ def publish_json(self, relative: Path, value: dict[str, Any], message: str) -> None:
90
+ for attempt in range(4):
91
+ try:
92
+ self.sync()
93
+ target = self.control_worktree / relative
94
+ atomic_json(target, value)
95
+ self.require_git("add", str(relative))
96
+ commit = self.git("commit", "-m", message, timeout=60)
97
+ if commit.returncode != 0:
98
+ combined = f"{commit.stdout}\n{commit.stderr}".lower()
99
+ if "nothing to commit" not in combined:
100
+ raise OperatorError(
101
+ f"git commit failed rc={commit.returncode}: "
102
+ f"{(commit.stderr or commit.stdout).strip()}"
103
+ )
104
+ self.require_git("push", "origin", f"HEAD:{self.branch}", timeout=90)
105
+ return
106
+ except Exception:
107
+ if attempt == 3:
108
+ raise
109
+ time.sleep(1.5 * (attempt + 1))
110
+
111
+ def status_payload(self, state: str, **extra: Any) -> dict[str, Any]:
112
+ return {
113
+ "schema_version": 1,
114
+ "state": state,
115
+ "instance_id": self.instance_id,
116
+ "host": socket.gethostname(),
117
+ "pid": os.getpid(),
118
+ "started_at_utc": self.started.astimezone(timezone.utc).isoformat(),
119
+ "updated_at_utc": utc_now().isoformat(),
120
+ "control_branch": self.branch,
121
+ "repo": str(self.repo),
122
+ "control_worktree": str(self.control_worktree),
123
+ **extra,
124
+ }
125
+
126
+ def publish_status(self, state: str, **extra: Any) -> None:
127
+ self.publish_json(
128
+ Path("automation/do_again/agent_status.json"),
129
+ self.status_payload(state, **extra),
130
+ f"Do Again {state}",
131
+ )
132
+
133
+ def ledger_path(self, request_id: str) -> Path:
134
+ return self.ledger_dir / f"{request_id}.json"
135
+
136
+ def receipt_relative(self, request_id: str) -> Path:
137
+ return Path(f"automation/do_again/receipts/{request_id}.json")
138
+
139
+ def receipt_exists(self, request_id: str) -> bool:
140
+ return (self.control_worktree / self.receipt_relative(request_id)).is_file()
141
+
142
+ def local_ledger(self, request_id: str) -> dict[str, Any] | None:
143
+ path = self.ledger_path(request_id)
144
+ if not path.is_file():
145
+ return None
146
+ value = read_json(path)
147
+ return value if isinstance(value, dict) else None
148
+
149
+ def write_ledger(self, request_id: str, value: dict[str, Any]) -> None:
150
+ atomic_json(self.ledger_path(request_id), value)
151
+
152
+ def make_receipt(
153
+ self,
154
+ *,
155
+ request: dict[str, Any],
156
+ state: str,
157
+ started_at: str,
158
+ result: dict[str, Any] | None = None,
159
+ error: str | None = None,
160
+ traceback_text: str | None = None,
161
+ ) -> dict[str, Any]:
162
+ return {
163
+ "schema_version": 1,
164
+ "request_id": request.get("request_id"),
165
+ "request_fingerprint": request_fingerprint(request),
166
+ "operation": request.get("operation"),
167
+ "state": state,
168
+ "started_at_utc": started_at,
169
+ "finished_at_utc": utc_now().isoformat(),
170
+ "agent_instance_id": self.instance_id,
171
+ "host": socket.gethostname(),
172
+ "result": result,
173
+ "error": error,
174
+ "traceback": traceback_text,
175
+ }
176
+
177
+ def result_succeeded(self, payload: dict[str, Any]) -> bool:
178
+ result = payload.get("result")
179
+ if not isinstance(result, dict):
180
+ return True
181
+ if "returncode" in result:
182
+ return result.get("returncode") == 0 and not result.get("timed_out")
183
+ if "retarget" in result:
184
+ retarget = result.get("retarget")
185
+ build = result.get("build")
186
+ return (
187
+ isinstance(build, dict)
188
+ and build.get("returncode") == 0
189
+ and isinstance(retarget, dict)
190
+ and retarget.get("returncode") == 0
191
+ )
192
+ return True
193
+
194
+ def publish_receipt(self, receipt: dict[str, Any]) -> None:
195
+ request_id = str(receipt["request_id"])
196
+ self.publish_json(
197
+ self.receipt_relative(request_id),
198
+ receipt,
199
+ f"Do Again receipt {request_id}: {receipt['state']}",
200
+ )
201
+
202
+ def schedule_self_restart(self) -> None:
203
+ label = str(
204
+ self.policy.get("agent_launchd_label", "io.github.tran-steven.do-again")
205
+ ).strip()
206
+ prefixes = tuple(str(value) for value in self.policy.get("launchctl_label_prefixes", []))
207
+ if not prefixes or not label.startswith(prefixes):
208
+ raise OperatorError("agent launchd label is not approved")
209
+ target = f"gui/{os.getuid()}/{label}"
210
+ subprocess.Popen(
211
+ ["/bin/launchctl", "kickstart", "-k", target],
212
+ stdin=subprocess.DEVNULL,
213
+ stdout=subprocess.DEVNULL,
214
+ stderr=subprocess.DEVNULL,
215
+ start_new_session=True,
216
+ close_fds=True,
217
+ )
218
+
219
+ def process_path(self, path: Path) -> bool:
220
+ try:
221
+ raw = read_json(path)
222
+ except Exception as exc:
223
+ return False
224
+ if not isinstance(raw, dict):
225
+ return False
226
+ request_id = str(raw.get("request_id") or "")
227
+ if not request_id or path.stem != request_id:
228
+ return False
229
+ if self.receipt_exists(request_id):
230
+ return False
231
+
232
+ ledger = self.local_ledger(request_id)
233
+ if ledger:
234
+ ledger_state = ledger.get("state")
235
+ stored_receipt = ledger.get("receipt")
236
+ if ledger_state == "terminal" and isinstance(stored_receipt, dict):
237
+ self.publish_receipt(stored_receipt)
238
+ return True
239
+ if ledger_state == "started":
240
+ started_at = str(ledger.get("started_at_utc") or utc_now().isoformat())
241
+ receipt = self.make_receipt(
242
+ request=raw,
243
+ state="blocked_ambiguous_replay",
244
+ started_at=started_at,
245
+ error=(
246
+ "A prior agent instance began this request but did not durably record "
247
+ "its completion. Refusing automatic replay."
248
+ ),
249
+ )
250
+ self.write_ledger(
251
+ request_id,
252
+ {
253
+ "state": "terminal",
254
+ "request_fingerprint": request_fingerprint(raw),
255
+ "receipt": receipt,
256
+ },
257
+ )
258
+ self.publish_receipt(receipt)
259
+ return True
260
+
261
+ started_at = utc_now().isoformat()
262
+ try:
263
+ request = validate_request(
264
+ raw,
265
+ max_ttl_seconds=int(self.policy.get("max_request_ttl_seconds", 3600)),
266
+ )
267
+ self.write_ledger(
268
+ request_id,
269
+ {
270
+ "state": "started",
271
+ "request_fingerprint": request_fingerprint(request),
272
+ "started_at_utc": started_at,
273
+ "agent_instance_id": self.instance_id,
274
+ },
275
+ )
276
+ payload = self.executor.execute(request)
277
+ receipt_state = "succeeded" if self.result_succeeded(payload) else "failed"
278
+ receipt = self.make_receipt(
279
+ request=request,
280
+ state=receipt_state,
281
+ started_at=started_at,
282
+ result=payload,
283
+ )
284
+ except OperatorError as exc:
285
+ receipt = self.make_receipt(
286
+ request=raw,
287
+ state="blocked",
288
+ started_at=started_at,
289
+ error=f"{type(exc).__name__}: {exc}",
290
+ )
291
+ except Exception as exc:
292
+ receipt = self.make_receipt(
293
+ request=raw,
294
+ state="error",
295
+ started_at=started_at,
296
+ error=f"{type(exc).__name__}: {exc}",
297
+ traceback_text=traceback.format_exc()[-12000:],
298
+ )
299
+
300
+ self.write_ledger(
301
+ request_id,
302
+ {
303
+ "state": "terminal",
304
+ "request_fingerprint": request_fingerprint(raw),
305
+ "receipt": receipt,
306
+ },
307
+ )
308
+ self.publish_receipt(receipt)
309
+ try:
310
+ self.publish_status(
311
+ "ready",
312
+ last_request_id=request_id,
313
+ last_request_state=receipt["state"],
314
+ )
315
+ except Exception:
316
+ pass
317
+ payload_result = receipt.get("result")
318
+ if (
319
+ receipt.get("state") == "succeeded"
320
+ and isinstance(payload_result, dict)
321
+ and isinstance(payload_result.get("result"), dict)
322
+ and payload_result["result"].get("restart_after_receipt") is True
323
+ ):
324
+ self.schedule_self_restart()
325
+ return True
326
+
327
+ def request_paths(self) -> list[Path]:
328
+ self.requests_dir.mkdir(parents=True, exist_ok=True)
329
+ self.receipts_dir.mkdir(parents=True, exist_ok=True)
330
+ values = []
331
+ for path in self.requests_dir.glob("*.json"):
332
+ try:
333
+ value = read_json(path)
334
+ except Exception:
335
+ continue
336
+ issued = str(value.get("issued_at_utc") or "") if isinstance(value, dict) else ""
337
+ values.append((issued, path.name, path))
338
+ values.sort()
339
+ return [path for _, _, path in values]
340
+
341
+ def run(self, once: bool = False) -> int:
342
+ self.ledger_dir.mkdir(parents=True, exist_ok=True)
343
+ try:
344
+ self.publish_status("ready")
345
+ except Exception as exc:
346
+ print(f"do-again initial status publish failed: {exc}", file=sys.stderr, flush=True)
347
+ while not self.stop_requested:
348
+ try:
349
+ self.sync()
350
+ did_work = False
351
+ for path in self.request_paths():
352
+ if self.stop_requested:
353
+ break
354
+ if self.process_path(path):
355
+ did_work = True
356
+ if once:
357
+ return 0
358
+ if not did_work:
359
+ time.sleep(self.poll_seconds)
360
+ except Exception as exc:
361
+ print(f"do-again loop error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
362
+ if once:
363
+ return 1
364
+ time.sleep(max(3.0, self.poll_seconds))
365
+ try:
366
+ self.publish_status("stopped")
367
+ except Exception:
368
+ pass
369
+ return 0
370
+
371
+
372
+ def parse_args() -> argparse.Namespace:
373
+ parser = argparse.ArgumentParser()
374
+ parser.add_argument("--repo", required=True)
375
+ parser.add_argument("--control-worktree", required=True)
376
+ parser.add_argument("--branch", default="operator-control")
377
+ parser.add_argument("--policy", required=True)
378
+ parser.add_argument("--state-dir", required=True)
379
+ parser.add_argument("--once", action="store_true")
380
+ return parser.parse_args()
381
+
382
+
383
+ def main() -> int:
384
+ args = parse_args()
385
+ agent = Agent(
386
+ repo=Path(args.repo),
387
+ control_worktree=Path(args.control_worktree),
388
+ branch=args.branch,
389
+ policy_path=Path(args.policy),
390
+ state_dir=Path(args.state_dir),
391
+ )
392
+
393
+ def stop_handler(signum: int, frame: Any) -> None:
394
+ agent.stop_requested = True
395
+
396
+ signal.signal(signal.SIGTERM, stop_handler)
397
+ signal.signal(signal.SIGINT, stop_handler)
398
+ return agent.run(once=args.once)
399
+
400
+
401
+ if __name__ == "__main__":
402
+ raise SystemExit(main())