co-agent-coder 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.
Files changed (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,147 @@
1
+ """Durable sync: get history off the VM after every checkpoint (FR-GIT-4).
2
+
3
+ **A commit is not a checkpoint; the sync is.** `/content` is exactly the disk
4
+ that disappears, so `checkpoint()` must run immediately after every commit:
5
+
6
+ * `mode = "push"` — push `coagent/<id>` to a configured remote. Denied to any
7
+ other ref by the policy allowlist.
8
+ * `mode = "bundle"` — write a `git bundle` to Drive (or any durable dir), with
9
+ no network at all. The first bundle carries full history; later ones are
10
+ `A..B` increments, so cost tracks the diff, not the repo.
11
+
12
+ A failed sync must **warn and continue**: losing a checkpoint attempt is not
13
+ worth stopping a task that is otherwise making progress (PRD FR-GIT-4).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from .git import GitError, GitRepo
23
+
24
+ __all__ = ["SyncResult", "sync_push", "sync_bundle", "checkpoint"]
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class SyncResult:
29
+ ok: bool
30
+ mode: str
31
+ message: str
32
+ ref: str = ""
33
+ path: Path | None = None
34
+ warnings: list[str] = field(default_factory=list)
35
+
36
+
37
+ def sync_push(
38
+ repo: GitRepo,
39
+ remote: str,
40
+ branch: str,
41
+ *,
42
+ policy=None,
43
+ attempts: int = 2,
44
+ ) -> SyncResult:
45
+ """Push the session branch. Retries once, then reports rather than raises."""
46
+ last = ""
47
+ for attempt in range(1, attempts + 1):
48
+ try:
49
+ result = repo.push(remote, branch, policy=policy)
50
+ except GitError as exc:
51
+ # A denial or a missing token is not a transient failure: report it
52
+ # once and let the caller fall back to a bundle.
53
+ return SyncResult(ok=False, mode="push", message=str(exc), ref=branch)
54
+ if result.ok:
55
+ return SyncResult(ok=True, mode="push", message=f"pushed {branch} to {remote}", ref=branch)
56
+ last = (result.stderr or result.stdout).strip()[:500]
57
+ if attempt < attempts:
58
+ time.sleep(1.5)
59
+ return SyncResult(
60
+ ok=False,
61
+ mode="push",
62
+ message=f"push to {remote} failed: {last or 'unknown error'}",
63
+ ref=branch,
64
+ )
65
+
66
+
67
+ def sync_bundle(
68
+ repo: GitRepo,
69
+ branch: str,
70
+ bundle_dir: str | Path,
71
+ *,
72
+ session_id: str,
73
+ previous_tip: str | None = None,
74
+ ) -> SyncResult:
75
+ """Write a bundle (full first, then incremental) and return its path."""
76
+ out_dir = Path(bundle_dir) / session_id
77
+ out_dir.mkdir(parents=True, exist_ok=True)
78
+ stamp = time.strftime("%Y%m%d-%H%M%S")
79
+ target = out_dir / f"{stamp}.bundle"
80
+
81
+ head = repo.head()
82
+ if previous_tip and repo.run(
83
+ "rev-parse", "--verify", "--quiet", f"{previous_tip}^{{commit}}", check=False
84
+ ).ok:
85
+ # Incremental: only the new commits. `^` makes it A..B (exclusive A).
86
+ spec = f"{previous_tip}..{branch}"
87
+ argv = ["bundle", "create", str(target), spec]
88
+ else:
89
+ argv = ["bundle", "create", str(target), "--all"]
90
+
91
+ result = repo.run(*argv, check=False)
92
+ if not result.ok:
93
+ return SyncResult(
94
+ ok=False,
95
+ mode="bundle",
96
+ message=f"git bundle failed: {result.stderr.strip()[:400]}",
97
+ ref=branch,
98
+ )
99
+ return SyncResult(
100
+ ok=True,
101
+ mode="bundle",
102
+ message=f"wrote bundle {target.name} ({target.stat().st_size / 1024:.0f} KB) at {head[:8]}",
103
+ ref=branch,
104
+ path=target,
105
+ )
106
+
107
+
108
+ def checkpoint(
109
+ repo: GitRepo,
110
+ *,
111
+ branch: str,
112
+ session_id: str,
113
+ mode: str = "push",
114
+ remote: str = "origin",
115
+ bundle_dir: str | Path | None = None,
116
+ previous_tip: str | None = None,
117
+ policy=None,
118
+ ) -> SyncResult:
119
+ """Sync after a commit. Falls back to a bundle if the push fails."""
120
+ warnings: list[str] = []
121
+ if mode == "bundle":
122
+ if bundle_dir is None:
123
+ return SyncResult(
124
+ ok=False,
125
+ mode="bundle",
126
+ message="durability.mode is 'bundle' but no bundle_dir is configured; "
127
+ "mount Drive or set durability.bundle_dir.",
128
+ ref=branch,
129
+ )
130
+ return sync_bundle(
131
+ repo, branch, bundle_dir, session_id=session_id, previous_tip=previous_tip
132
+ )
133
+
134
+ pushed = sync_push(repo, remote, branch, policy=policy)
135
+ if pushed.ok:
136
+ return pushed
137
+ warnings.append(pushed.message)
138
+ if bundle_dir is None:
139
+ pushed.warnings = warnings
140
+ return pushed
141
+
142
+ # Fallback: checkpoint offline so the work survives eviction.
143
+ bundled = sync_bundle(repo, branch, bundle_dir, session_id=session_id, previous_tip=previous_tip)
144
+ bundled.warnings = warnings
145
+ if bundled.ok:
146
+ bundled.message = f"{pushed.message}; fell back to {bundled.message}"
147
+ return bundled
coagent/gitx/git.py ADDED
@@ -0,0 +1,351 @@
1
+ """A thin, jailed `git` runner.
2
+
3
+ Every git call goes through here for four reasons:
4
+
5
+ 1. **No tokens in the environment.** The child gets a scrubbed env; credentials
6
+ arrive only through `GIT_ASKPASS`, never in a remote URL (DESIGN §9.2).
7
+ 2. **No accidental prompts.** `GIT_TERMINAL_PROMPT=0` turns a missing credential
8
+ into an error instead of a hang — a hang in a notebook looks like a freeze.
9
+ 3. **Machine-readable output.** `-z`/`--porcelain` everywhere, so parsing never
10
+ depends on locale or colour.
11
+ 4. **One place for the push allowlist** (FR-GIT-8): `push()` refuses any ref
12
+ outside `refs/heads/coagent/` and any unconfigured remote before spawn.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import os
19
+ import subprocess
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from ..safety.policy import Policy, Verdict
24
+
25
+ __all__ = ["GitError", "GitRepo", "GitResult", "format_askpass_script"]
26
+
27
+
28
+ class GitError(RuntimeError):
29
+ """A git command failed. Carries git's own stderr for the report."""
30
+
31
+ def __init__(self, message: str, *, argv: list[str] | None = None, stderr: str = "") -> None:
32
+ super().__init__(message)
33
+ self.argv = argv or []
34
+ self.stderr = stderr
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class GitResult:
39
+ argv: list[str]
40
+ returncode: int
41
+ stdout: str
42
+ stderr: str
43
+
44
+ @property
45
+ def ok(self) -> bool:
46
+ return self.returncode == 0
47
+
48
+
49
+ def format_askpass_script() -> str:
50
+ """A tiny POSIX `GIT_ASKPASS` helper that echoes the token from the env.
51
+
52
+ Written to `.coagent/tmp/askpass.sh` (mode 0700) instead of embedding the
53
+ token in the remote URL, which would persist in `.git/config`, appear in
54
+ `git remote -v`, and be captured in every Drive bundle.
55
+ """
56
+ return (
57
+ "#!/bin/sh\n"
58
+ "# coAgent credential helper. The token is read from the environment of the\n"
59
+ "# git child process only; it is never written to disk or to .git/config.\n"
60
+ 'case "$1" in\n'
61
+ ' *Username*) printf \'%s\\n\' "${COAGENT_GIT_USERNAME:-x-access-token}" ;;\n'
62
+ ' *Password*) printf \'%s\\n\' "${COAGENT_GIT_TOKEN:-}" ;;\n'
63
+ ' *) printf \'%s\\n\' "${COAGENT_GIT_TOKEN:-}" ;;\n'
64
+ "esac\n"
65
+ )
66
+
67
+
68
+ class GitRepo:
69
+ """Git operations scoped to one working tree."""
70
+
71
+ def __init__(
72
+ self,
73
+ root: str | Path,
74
+ *,
75
+ policy: Policy | None = None,
76
+ author_name: str = "coAgent",
77
+ author_email: str = "coagent@users.noreply.github.com",
78
+ token: str | None = None,
79
+ timeout_s: int = 120,
80
+ ) -> None:
81
+ self.root = Path(root).resolve()
82
+ self.policy = policy
83
+ self.author_name = author_name
84
+ self.author_email = author_email
85
+ self._token = token
86
+ self.timeout_s = timeout_s
87
+
88
+ # --------------------------------------------------------------- plumbing
89
+
90
+ def _env(self) -> dict[str, str]:
91
+ env = {k: v for k, v in os.environ.items() if "TOKEN" not in k.upper() and "SECRET" not in k.upper()}
92
+ env.update(
93
+ {
94
+ "GIT_TERMINAL_PROMPT": "0",
95
+ "GIT_AUTHOR_NAME": self.author_name,
96
+ "GIT_AUTHOR_EMAIL": self.author_email,
97
+ "GIT_COMMITTER_NAME": self.author_name,
98
+ "GIT_COMMITTER_EMAIL": self.author_email,
99
+ # Deterministic plumbing: no pager, no colour, no quoting.
100
+ "GIT_PAGER": "cat",
101
+ "PAGER": "cat",
102
+ "LC_ALL": "C",
103
+ # No pager/colour/quoting. Crucially we do NOT override
104
+ # core.autocrlf: whatever the machine and the repo already use
105
+ # is what the index was built with, and forcing a different
106
+ # value makes every CRLF file look permanently modified.
107
+ }
108
+ )
109
+ # Preserve the identity so `git commit` is attributable, but never the
110
+ # credential: that is injected per-call only for the ops that need it.
111
+ if self._token and self._askpass_path().exists():
112
+ env["GIT_ASKPASS"] = str(self._askpass_path())
113
+ env["COAGENT_GIT_TOKEN"] = self._token
114
+ return env
115
+
116
+ def _askpass_path(self) -> Path:
117
+ return self.root / ".coagent" / "tmp" / "askpass.sh"
118
+
119
+ def install_askpass(self) -> Path:
120
+ """Write the credential helper. Called once at bootstrap (DESIGN §11)."""
121
+ path = self._askpass_path()
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ path.write_text(format_askpass_script(), encoding="utf-8")
124
+ with contextlib.suppress(OSError): # Windows/ACL edge case
125
+ path.chmod(0o700)
126
+ return path
127
+
128
+ def run(self, *args: str, cwd: Path | None = None, check: bool = True) -> GitResult:
129
+ argv = ["git", *args]
130
+ proc = subprocess.run(
131
+ argv,
132
+ cwd=str(cwd or self.root),
133
+ env=self._env(),
134
+ capture_output=True,
135
+ text=True,
136
+ errors="replace",
137
+ timeout=self.timeout_s,
138
+ )
139
+ result = GitResult(argv=argv, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
140
+ if check and not result.ok:
141
+ raise GitError(
142
+ f"git {' '.join(args)} failed (exit {result.returncode}): {proc.stderr.strip()[:800]}",
143
+ argv=argv,
144
+ stderr=proc.stderr,
145
+ )
146
+ return result
147
+
148
+ # ------------------------------------------------------------------ state
149
+
150
+ def is_repo(self) -> bool:
151
+ return (self.root / ".git").exists() and self.run(
152
+ "rev-parse", "--git-dir", check=False
153
+ ).ok
154
+
155
+ def head(self) -> str:
156
+ return self.run("rev-parse", "HEAD").stdout.strip()
157
+
158
+ def current_branch(self) -> str:
159
+ return self.run("rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
160
+
161
+ def branch_exists(self, name: str) -> bool:
162
+ return self.run("rev-parse", "--verify", "--quiet", f"refs/heads/{name}", check=False).ok
163
+
164
+ def remote_branch_exists(self, remote: str, name: str) -> bool:
165
+ return self.run(
166
+ "rev-parse", "--verify", "--quiet", f"refs/remotes/{remote}/{name}", check=False
167
+ ).ok
168
+
169
+ def remotes(self) -> list[str]:
170
+ out = self.run("remote", check=False).stdout
171
+ return [line.strip() for line in out.splitlines() if line.strip()]
172
+
173
+ def status_porcelain(self, *, ignore: tuple[str, ...] = ()) -> list[str]:
174
+ """Porcelain entries, optionally ignoring paths by prefix.
175
+
176
+ coAgent's own runtime state (`.coagent/lock.json`, `tmp/`, `sessions/`)
177
+ must not count as user dirt, or every session would refuse to start on
178
+ its own lock file.
179
+ """
180
+ out = self.run("status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
181
+ entries = [entry for entry in out.split("\x00") if entry.strip()]
182
+ if not ignore:
183
+ return entries
184
+ kept: list[str] = []
185
+ for entry in entries:
186
+ path = entry[3:] if len(entry) > 3 else entry
187
+ if any(path == prefix or path.startswith(prefix) for prefix in ignore):
188
+ continue
189
+ kept.append(entry)
190
+ return kept
191
+
192
+ def is_dirty(self, *, ignore: tuple[str, ...] = ()) -> bool:
193
+ return bool(self.status_porcelain(ignore=ignore))
194
+
195
+ def changed_paths(self, *, ignore: tuple[str, ...] = ()) -> list[str]:
196
+ """Paths from porcelain entries, with the two-char status stripped."""
197
+ paths: list[str] = []
198
+ for entry in self.status_porcelain(ignore=ignore):
199
+ # Format: "XY <path>" (renames have an extra NUL-separated source).
200
+ path = entry[3:] if len(entry) > 3 else entry
201
+ if path:
202
+ paths.append(path)
203
+ return paths
204
+
205
+ def staged_paths(self) -> list[str]:
206
+ out = self.run("diff", "--cached", "--name-only", "-z").stdout
207
+ return [p for p in out.split("\x00") if p]
208
+
209
+ def has_staged_changes(self) -> bool:
210
+ return self.run("diff", "--cached", "--quiet", check=False).returncode == 1
211
+
212
+ def diff_stat(self, *, staged: bool = True) -> str:
213
+ args = ["diff", "--stat"] + (["--cached"] if staged else [])
214
+ return self.run(*args, check=False).stdout.strip()
215
+
216
+ def diff(self, *, staged: bool = True) -> str:
217
+ args = ["diff"] + (["--cached"] if staged else [])
218
+ return self.run(*args, check=False).stdout
219
+
220
+ # -------------------------------------------------------------- identity
221
+
222
+ def ensure_identity(self) -> None:
223
+ """Set user.name/user.email locally if unset — otherwise the first
224
+ commit fails and the whole session looks broken (DESIGN §11)."""
225
+ for key, value in (("user.name", self.author_name), ("user.email", self.author_email)):
226
+ current = self.run("config", "--get", key, check=False)
227
+ if not current.ok or not current.stdout.strip():
228
+ self.run("config", key, value)
229
+
230
+ # ------------------------------------------------------------------ write
231
+
232
+ def switch(self, branch: str, *, create: bool = False) -> None:
233
+ if create:
234
+ self.run("switch", "-c", branch)
235
+ else:
236
+ self.run("switch", branch)
237
+
238
+ def add(self, *paths: str) -> None:
239
+ self.run("add", "--", *paths)
240
+
241
+ def add_all_tracked(self) -> None:
242
+ """Stage modifications and deletions, but not untracked files."""
243
+ self.run("add", "-u")
244
+
245
+ def commit(self, message: str) -> str:
246
+ """Plain commit with a literal message (used mostly by tests)."""
247
+ self._commit_stdin(message if message.endswith("\n") else message + "\n")
248
+ return self.head()
249
+
250
+ def _scrub_commit_editor(self) -> None:
251
+ """Nothing to do; kept as the seam if a hooks policy is added later."""
252
+
253
+ def _commit_stdin(self, message: str, *, argv_extra: list[str] | None = None) -> None:
254
+ argv = ["git", "commit", "--no-verify", "--cleanup=verbatim", "-F", "-", *(argv_extra or [])]
255
+ proc = subprocess.run(
256
+ argv,
257
+ cwd=str(self.root),
258
+ env=self._env(),
259
+ input=message,
260
+ capture_output=True,
261
+ text=True,
262
+ errors="replace",
263
+ timeout=self.timeout_s,
264
+ )
265
+ if proc.returncode != 0:
266
+ raise GitError(
267
+ f"git commit failed (exit {proc.returncode}): {proc.stderr.strip()[:800]}",
268
+ argv=argv,
269
+ stderr=proc.stderr,
270
+ )
271
+
272
+ def commit_with_trailers(self, subject: str, body: str, trailers: list[tuple[str, str]]) -> str:
273
+ """Commit with a message built from a subject, body, and trailers.
274
+
275
+ Built in Python and passed via `-F -` on stdin so a trailer containing
276
+ newlines or quotes cannot break the argument list.
277
+ """
278
+ self._commit_stdin(build_commit_message(subject, body, trailers))
279
+ return self.head()
280
+
281
+ def log_oneline(self, *, grep: str = "", max_count: int = 20, rev: str = "HEAD") -> list[str]:
282
+ args = ["log", "--oneline", f"-n{max_count}"]
283
+ if grep:
284
+ args.append(f"--grep={grep}")
285
+ args.append(rev)
286
+ return [line for line in self.run(*args, check=False).stdout.splitlines() if line.strip()]
287
+
288
+ # ------------------------------------------------------------------- push
289
+
290
+ def push(self, remote: str, ref: str, *, policy: Policy | None = None) -> GitResult:
291
+ """Push one ref, after the allowlist check (FR-GIT-8)."""
292
+ active = policy or self.policy
293
+ if active is not None:
294
+ decision = active.check_push(remote, ref)
295
+ if decision.verdict is not Verdict.ALLOW:
296
+ raise GitError(f"push denied: {decision.reason} ({decision.rule})")
297
+ else: # no policy configured: fall back to the narrow default
298
+ normalized = ref if ref.startswith("refs/") else f"refs/heads/{ref}"
299
+ if not normalized.startswith("refs/heads/coagent/"):
300
+ raise GitError(f"push denied: only coagent/* refs may be pushed, not {normalized}")
301
+ if not self._token:
302
+ raise GitError(
303
+ "no GitHub token is loaded; add one to Colab secrets to push. "
304
+ "Fall back to durability.mode = 'bundle' to checkpoint offline."
305
+ )
306
+ branch = ref.removeprefix("refs/heads/")
307
+ return self.run("push", "--set-upstream", remote, f"HEAD:refs/heads/{branch}", check=False)
308
+
309
+ def fetch(self, remote: str, *, depth: int | None = None) -> GitResult:
310
+ args = ["fetch", remote]
311
+ if depth:
312
+ args.append(f"--depth={depth}")
313
+ return self.run(*args, check=False)
314
+
315
+ def clone(self, url: str, dest: Path, *, depth: int = 1) -> None:
316
+ """Clone into `dest`. The URL must not carry credentials (DESIGN §9.2)."""
317
+ if "@" in url.split("://", 1)[-1].split("/", 1)[0]:
318
+ raise GitError(
319
+ "the clone URL contains embedded credentials. Use a bare https URL; "
320
+ "coAgent injects the token through GIT_ASKPASS instead."
321
+ )
322
+ argv = ["git", "clone", f"--depth={depth}", url, str(dest)]
323
+ proc = subprocess.run(
324
+ argv,
325
+ cwd=str(dest.parent),
326
+ env=self._env(),
327
+ capture_output=True,
328
+ text=True,
329
+ errors="replace",
330
+ timeout=max(self.timeout_s, 600),
331
+ )
332
+ if proc.returncode != 0:
333
+ raise GitError(
334
+ f"git clone failed (exit {proc.returncode}): {proc.stderr.strip()[:800]}",
335
+ argv=argv,
336
+ stderr=proc.stderr,
337
+ )
338
+
339
+
340
+ def build_commit_message(subject: str, body: str, trailers: list[tuple[str, str]]) -> str:
341
+ """Subject, blank line, body, blank line, trailers — in that order.
342
+
343
+ `--cleanup=verbatim` keeps the trailers' exact spelling, which the
344
+ `--grep="Coagent-Step"` queries in the PRD depend on.
345
+ """
346
+ parts = [subject.strip()]
347
+ if body.strip():
348
+ parts.append(body.strip())
349
+ if trailers:
350
+ parts.append("\n".join(f"{key}: {value}" for key, value in trailers))
351
+ return "\n\n".join(parts) + "\n"
coagent/gitx/lock.py ADDED
@@ -0,0 +1,157 @@
1
+ """Single-writer session lock (PRD FR-GIT-6).
2
+
3
+ `.coagent/lock.json` holds `{session_id, pid, started_at, heartbeat_at,
4
+ expires_at, host}`. A live lock means a second session refuses; an expired one
5
+ is stale and can be taken over. `--force` takes over even a live lock after
6
+ reporting which session held it.
7
+
8
+ This is advisory and deliberately simple: coAgent is a single-user tool, and a
9
+ stateful lock protocol on an ephemeral VM is more failure modes than it buys.
10
+ The real duplicate-work guard is that the session branch lives in git.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import socket
18
+ import time
19
+ from dataclasses import asdict, dataclass
20
+ from pathlib import Path
21
+
22
+ __all__ = ["LockInfo", "SessionLock", "LockHeldError"]
23
+
24
+ LOCK_RELPATH = Path(".coagent") / "lock.json"
25
+ DEFAULT_TTL_S = 30 * 60
26
+
27
+
28
+ class LockHeldError(RuntimeError):
29
+ """Another session holds the lock."""
30
+
31
+ def __init__(self, other: LockInfo) -> None:
32
+ self.other = other
33
+ minutes = max(0, int((other.expires_at - time.time()) / 60))
34
+ super().__init__(
35
+ f"session {other.session_id} holds the lock on this repo "
36
+ f"(host {other.host}, expires in {minutes} min). "
37
+ "Use --force to take over, or `coagent resume` to continue that session."
38
+ )
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class LockInfo:
43
+ session_id: str
44
+ pid: int
45
+ host: str
46
+ started_at: float
47
+ heartbeat_at: float
48
+ expires_at: float
49
+
50
+ def is_expired(self, *, now: float | None = None) -> bool:
51
+ return (now or time.time()) >= self.expires_at
52
+
53
+ def same_process(self) -> bool:
54
+ """True when this lock is held by the current interpreter."""
55
+ return self.host == socket.gethostname() and self.pid == os.getpid()
56
+
57
+
58
+ class SessionLock:
59
+ def __init__(self, repo_root: str | Path, session_id: str, *, ttl_s: int = DEFAULT_TTL_S) -> None:
60
+ self.root = Path(repo_root).resolve()
61
+ self.path = self.root / LOCK_RELPATH
62
+ self.session_id = session_id
63
+ self.ttl_s = ttl_s
64
+ self.info: LockInfo | None = None
65
+
66
+ # ------------------------------------------------------------------ read
67
+
68
+ def read(self) -> LockInfo | None:
69
+ if not self.path.exists():
70
+ return None
71
+ try:
72
+ data = json.loads(self.path.read_text(encoding="utf-8"))
73
+ except (OSError, json.JSONDecodeError):
74
+ return None
75
+ required = set(LockInfo.__dataclass_fields__)
76
+ if not required <= set(data):
77
+ return None
78
+ return LockInfo(**{k: data[k] for k in required})
79
+
80
+ # ----------------------------------------------------------------- write
81
+
82
+ def acquire(self, *, force: bool = False) -> LockInfo:
83
+ existing = self.read()
84
+ if existing is not None and not existing.is_expired() and not existing.same_process() and not force:
85
+ # A live lock from another process/host blocks us unless forced.
86
+ raise LockHeldError(existing)
87
+ now = time.time()
88
+ info = LockInfo(
89
+ session_id=self.session_id,
90
+ pid=os.getpid(),
91
+ host=socket.gethostname(),
92
+ started_at=now,
93
+ heartbeat_at=now,
94
+ expires_at=now + self.ttl_s,
95
+ )
96
+ self._write(info)
97
+ self.info = info
98
+ return info
99
+
100
+ def heartbeat(self) -> LockInfo:
101
+ """Refresh the expiry. Called from the checkpoint loop (FR-REL-1)."""
102
+ current = self.read()
103
+ if current is not None and current.session_id != self.session_id:
104
+ raise LockHeldError(current)
105
+ base = current or (self.info or self._fresh())
106
+ now = time.time()
107
+ info = LockInfo(
108
+ session_id=self.session_id,
109
+ pid=os.getpid(),
110
+ host=socket.gethostname(),
111
+ started_at=base.started_at,
112
+ heartbeat_at=now,
113
+ expires_at=now + self.ttl_s,
114
+ )
115
+ self._write(info)
116
+ self.info = info
117
+ return info
118
+
119
+ def release(self) -> bool:
120
+ """Remove the lock, but never someone else's."""
121
+ current = self.read()
122
+ if current is None:
123
+ return False
124
+ if current.session_id != self.session_id and not current.is_expired():
125
+ return False
126
+ try:
127
+ self.path.unlink()
128
+ except OSError:
129
+ return False
130
+ self.info = None
131
+ return True
132
+
133
+ def __enter__(self) -> SessionLock:
134
+ self.acquire()
135
+ return self
136
+
137
+ def __exit__(self, *exc: object) -> None:
138
+ self.release()
139
+
140
+ # ---------------------------------------------------------------- helpers
141
+
142
+ def _write(self, info: LockInfo) -> None:
143
+ self.path.parent.mkdir(parents=True, exist_ok=True)
144
+ tmp = self.path.with_suffix(".tmp")
145
+ tmp.write_text(json.dumps(asdict(info), indent=2), encoding="utf-8")
146
+ os.replace(tmp, self.path) # atomic on POSIX and Windows
147
+
148
+ def _fresh(self) -> LockInfo:
149
+ now = time.time()
150
+ return LockInfo(
151
+ session_id=self.session_id,
152
+ pid=os.getpid(),
153
+ host=socket.gethostname(),
154
+ started_at=now,
155
+ heartbeat_at=now,
156
+ expires_at=now + self.ttl_s,
157
+ )