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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
coagent/gitx/durable.py
ADDED
|
@@ -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
|
+
)
|