autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/__init__.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""The agent worktree's git surface, as the controller reads it (ADR 0005 D6.3-D6.5).
|
|
2
|
+
|
|
3
|
+
Mode-independent: the redirect these close needs no sandbox, only a
|
|
4
|
+
writable ``.git`` file, which every agent has. The engine calls these for a
|
|
5
|
+
REMOTE run's issue worktree, whatever ``sandbox.mode`` says (#168).
|
|
6
|
+
|
|
7
|
+
- **Binding (D6.4).** :func:`bound_admin_dir` finds the worktree's admin
|
|
8
|
+
dir through the repository's registry: exactly one
|
|
9
|
+
``<common>/worktrees/<id>/gitdir`` names ``<worktree>/.git``, and the
|
|
10
|
+
worktree's ``.git`` is a regular file naming that admin dir, absolute or
|
|
11
|
+
relative (``worktree.useRelativePaths``). It replaces asking ``git -C
|
|
12
|
+
<worktree>`` where its repository is, which follows the agent-writable
|
|
13
|
+
``.git`` file. :func:`registered_worktree_roots` is the registry's list of
|
|
14
|
+
working-tree roots, what ``git worktree list`` reports, read from the same
|
|
15
|
+
entries without git and without following a link: ``git worktree list``
|
|
16
|
+
resolves every listed worktree's ``HEAD``, through a link the agent
|
|
17
|
+
planted in its admin dir (#223). Both read the registry through a
|
|
18
|
+
descriptor opened ``O_NOFOLLOW`` (:func:`_registry_entries`): a registry
|
|
19
|
+
that is itself a link is refused, never listed.
|
|
20
|
+
- **Candidate (D6.3).** :func:`read_candidate` reads the worktree's
|
|
21
|
+
``HEAD`` as ``worktrees/<id>/HEAD`` with ``--git-dir=<git common dir>``,
|
|
22
|
+
after ``lstat`` found a regular file there, reached from the common dir
|
|
23
|
+
through real directories (:func:`head_problem`): never
|
|
24
|
+
through the worktree's ``.git`` file, the admin dir's ``commondir`` or
|
|
25
|
+
``gitdir``, or ``git -C <worktree>``. An attached ``HEAD`` is told by
|
|
26
|
+
``symbolic-ref --no-recurse``, which never opens the reference it names
|
|
27
|
+
(#223).
|
|
28
|
+
- **Strict import (D6.5).** :func:`import_private_objects` copies the
|
|
29
|
+
objects reachable from that ``HEAD`` which the shared store lacks out of
|
|
30
|
+
the issue's private object directory (an enforced launch's overlay upper
|
|
31
|
+
layer), through ``index-pack --strict``, which writes each object under
|
|
32
|
+
the id it computes from its bytes. It reads the commit ``HEAD`` holds
|
|
33
|
+
itself, after the candidate read's ``lstat``: a link there fails the
|
|
34
|
+
import before anything is read through it, and an attached ``HEAD``
|
|
35
|
+
imports nothing. :func:`private_objects_problem` walks the directory
|
|
36
|
+
first without following a link: a root that is not a real directory
|
|
37
|
+
reached from the common dir through real directories, an alternates
|
|
38
|
+
file, or an entry that is neither a directory, a regular file nor an
|
|
39
|
+
overlay whiteout, is tampering
|
|
40
|
+
(:class:`~autoforge.errors.SandboxTamperError`). Its ``index-pack`` is
|
|
41
|
+
the one step that writes the shared store, so it alone runs under the
|
|
42
|
+
shared-Git lock (L1, ADR 0008 D12.1) and with automatic maintenance off
|
|
43
|
+
(D12.3).
|
|
44
|
+
|
|
45
|
+
Every git process is the controller's own hardened local request
|
|
46
|
+
(:func:`~autoforge.git_transport.local_git_request`, ADR 0004 D7.3, D7.5);
|
|
47
|
+
the private directory is named through ``GIT_ALTERNATE_OBJECT_DIRECTORIES``
|
|
48
|
+
for the processes that read it, as one double-quoted entry
|
|
49
|
+
(:func:`alternate_entry`) so that a ``:`` in its path does not split it,
|
|
50
|
+
and never added to the shared store's alternates.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import os
|
|
56
|
+
import re
|
|
57
|
+
import shutil
|
|
58
|
+
import stat
|
|
59
|
+
import tempfile
|
|
60
|
+
from collections.abc import Callable
|
|
61
|
+
from contextlib import AbstractContextManager
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
from .errors import GitLockTimeout, LockError, SandboxTamperError, VerificationError
|
|
66
|
+
from .executor import ExecutionRequest, ExecutionResult, execute
|
|
67
|
+
from .git_transport import NO_AUTO_MAINTENANCE, local_git_request
|
|
68
|
+
from .locking import SharedGitLock
|
|
69
|
+
from .redaction import redact
|
|
70
|
+
|
|
71
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
72
|
+
|
|
73
|
+
GIT_TIMEOUT_SECONDS = 120
|
|
74
|
+
# An import moves at most one issue's new objects; a large one is a pack.
|
|
75
|
+
IMPORT_TIMEOUT_SECONDS = 600
|
|
76
|
+
# A pointer file holds one path.
|
|
77
|
+
_MAX_POINTER_BYTES = 4096
|
|
78
|
+
# A registry directory: a link there fails the open instead of being followed.
|
|
79
|
+
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
80
|
+
# The registry entries and private-directory entries a walk visits before
|
|
81
|
+
# it fails closed: a store bigger than this is not one issue's work.
|
|
82
|
+
MAX_REGISTRY_ENTRIES = 10_000
|
|
83
|
+
MAX_PRIVATE_ENTRIES = 1_000_000
|
|
84
|
+
_SHA_RE = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
85
|
+
# The files that make git read objects from somewhere else (D6.5).
|
|
86
|
+
ALTERNATES_FILES = ("info/alternates", "info/http-alternates")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run(runner: Runner | None, request: ExecutionRequest) -> ExecutionResult:
|
|
90
|
+
return (runner or execute)(request)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _detail(res: ExecutionResult) -> str:
|
|
94
|
+
lines = (res.stderr or res.stdout or "").strip().splitlines()
|
|
95
|
+
return f": {redact(lines[-1])[:200]}" if lines else ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ended(res: ExecutionResult) -> str:
|
|
99
|
+
return "timed out" if res.timed_out else f"exited {res.exit_code}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_pointer(path: Path | str, dir_fd: int | None = None) -> str | None:
|
|
103
|
+
"""A pointer file's single line, or ``None`` unless it is a regular file.
|
|
104
|
+
|
|
105
|
+
Opened without following a link and without blocking, and checked again
|
|
106
|
+
on the descriptor: a file swapped for a FIFO after the ``lstat`` neither
|
|
107
|
+
blocks the read nor is read. ``dir_fd``: ``path`` is a name in that
|
|
108
|
+
directory, reached through no path at all.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
if not stat.S_ISREG(os.lstat(path, dir_fd=dir_fd).st_mode):
|
|
112
|
+
return None
|
|
113
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=dir_fd)
|
|
114
|
+
except OSError:
|
|
115
|
+
return None
|
|
116
|
+
with os.fdopen(fd, "rb") as handle:
|
|
117
|
+
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
|
118
|
+
return None
|
|
119
|
+
data = handle.read(_MAX_POINTER_BYTES + 1)
|
|
120
|
+
if len(data) > _MAX_POINTER_BYTES or b"\0" in data:
|
|
121
|
+
return None
|
|
122
|
+
text = data.decode("utf-8", "replace").strip()
|
|
123
|
+
return text if text and "\n" not in text else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _names(value: str, base: Path, expected: Path) -> bool:
|
|
127
|
+
"""Whether the pointer ``value`` (absolute, or relative to ``base``) names ``expected``."""
|
|
128
|
+
named = os.path.normpath(value if os.path.isabs(value) else os.path.join(base, value))
|
|
129
|
+
return named in (str(expected), os.path.realpath(expected))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class _RegistryEntry:
|
|
134
|
+
"""A registry entry ``<common>/worktrees/<id>`` with a regular ``gitdir`` file."""
|
|
135
|
+
|
|
136
|
+
admin: Path
|
|
137
|
+
gitdir: str
|
|
138
|
+
locked: bool
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _registry_entries(
|
|
142
|
+
registry: Path, refuse: Callable[[str], VerificationError]
|
|
143
|
+
) -> list[_RegistryEntry] | None:
|
|
144
|
+
"""The registry's entries, read without following a link (D6.4, #223).
|
|
145
|
+
|
|
146
|
+
``None`` when ``registry`` is absent. The registry itself is ``lstat``-ed
|
|
147
|
+
and opened ``O_NOFOLLOW``, and each entry and its files are reached from
|
|
148
|
+
that descriptor, never by path: a registry that is a link (an agent
|
|
149
|
+
without an enforced sandbox can replace it) is refused before the
|
|
150
|
+
directory it names is opened, and so is one that is not a directory,
|
|
151
|
+
cannot be read, or holds more than :data:`MAX_REGISTRY_ENTRIES`
|
|
152
|
+
entries. An entry that is not a real directory, or whose ``gitdir`` is
|
|
153
|
+
not a regular file (:func:`_read_pointer`), is left out.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
mode = os.lstat(registry).st_mode
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return None
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
raise refuse(f"{registry} cannot be checked ({exc.strerror})") from None
|
|
161
|
+
if stat.S_ISLNK(mode):
|
|
162
|
+
raise refuse(f"{registry} is a link: the controller lists no registry through one")
|
|
163
|
+
if not stat.S_ISDIR(mode):
|
|
164
|
+
raise refuse(f"{registry} is not a directory")
|
|
165
|
+
found: list[_RegistryEntry] = []
|
|
166
|
+
try:
|
|
167
|
+
# A link swapped in since the lstat fails the open.
|
|
168
|
+
fd = os.open(registry, _DIR_FLAGS)
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise refuse(f"{registry} cannot be read ({exc.strerror})") from None
|
|
171
|
+
try:
|
|
172
|
+
with os.scandir(fd) as entries:
|
|
173
|
+
for count, entry in enumerate(entries):
|
|
174
|
+
if count >= MAX_REGISTRY_ENTRIES:
|
|
175
|
+
raise refuse(f"{registry} holds more than {MAX_REGISTRY_ENTRIES} entries")
|
|
176
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
admin_fd = os.open(entry.name, _DIR_FLAGS, dir_fd=fd)
|
|
180
|
+
except OSError:
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
value = _read_pointer("gitdir", admin_fd)
|
|
184
|
+
if value is None:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
os.lstat("locked", dir_fd=admin_fd)
|
|
188
|
+
locked = True
|
|
189
|
+
except OSError:
|
|
190
|
+
locked = False
|
|
191
|
+
finally:
|
|
192
|
+
os.close(admin_fd)
|
|
193
|
+
found.append(_RegistryEntry(registry / entry.name, value, locked))
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
raise refuse(f"{registry} cannot be read ({exc.strerror})") from None
|
|
196
|
+
finally:
|
|
197
|
+
os.close(fd)
|
|
198
|
+
return found
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def bound_admin_dir(common: Path, worktree: Path) -> Path:
|
|
202
|
+
"""The admin dir ``<common>/worktrees/<id>`` the registry binds ``worktree`` to (D6.4).
|
|
203
|
+
|
|
204
|
+
``worktree`` is the literal path the controller derived (its link and
|
|
205
|
+
location checks are the caller's). Refused, with a
|
|
206
|
+
:class:`VerificationError` naming the inconsistency and ``git worktree
|
|
207
|
+
repair``, unless exactly one registry entry's ``gitdir`` names
|
|
208
|
+
``<worktree>/.git`` and the worktree's ``.git`` is a regular file
|
|
209
|
+
naming that entry. The registry is read without following a link
|
|
210
|
+
(:func:`_registry_entries`).
|
|
211
|
+
"""
|
|
212
|
+
dotgit = worktree / ".git"
|
|
213
|
+
registry = common / "worktrees"
|
|
214
|
+
|
|
215
|
+
def refuse(what: str) -> VerificationError:
|
|
216
|
+
return VerificationError(
|
|
217
|
+
f"the agent worktree {worktree} is not bound to the repository at {common}: "
|
|
218
|
+
f"{what}. The controller reads a worktree only through the registry entry that "
|
|
219
|
+
"names it; if the worktree or the repository was moved, run `git worktree repair` "
|
|
220
|
+
f"from the repository (or `git worktree repair {worktree}`), then 'resume'"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
entries = _registry_entries(registry, refuse)
|
|
224
|
+
if entries is None:
|
|
225
|
+
raise refuse(f"the repository registers no worktree ({registry} is absent)")
|
|
226
|
+
matches = [e.admin for e in entries if _names(e.gitdir, e.admin, dotgit)]
|
|
227
|
+
if not matches:
|
|
228
|
+
raise refuse(f"no entry of {registry} has a gitdir naming {dotgit}")
|
|
229
|
+
if len(matches) > 1:
|
|
230
|
+
names = ", ".join(sorted(m.name for m in matches))
|
|
231
|
+
raise refuse(f"more than one entry of {registry} ({names}) has a gitdir naming {dotgit}")
|
|
232
|
+
admin = matches[0]
|
|
233
|
+
value = _read_pointer(dotgit)
|
|
234
|
+
if value is None or not value.startswith("gitdir: "):
|
|
235
|
+
raise refuse(f"{dotgit} is not a regular file of the form 'gitdir: <path>'")
|
|
236
|
+
if not _names(value[len("gitdir: ") :].strip(), worktree, admin):
|
|
237
|
+
raise refuse(f"{dotgit} does not name the registry entry {admin}")
|
|
238
|
+
return admin
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _less_dotgit(path: str) -> str:
|
|
242
|
+
"""``path`` less a trailing ``/.git``: how git names a worktree after its ``.git``."""
|
|
243
|
+
return path[: -len("/.git")] if path.endswith("/.git") else path
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def registered_worktree_roots(common: Path) -> set[Path]:
|
|
247
|
+
"""The working-tree roots the repository's registry names, resolved (D6.4, #223).
|
|
248
|
+
|
|
249
|
+
What ``git worktree list`` reports, read without git: git also resolves
|
|
250
|
+
every listed worktree's ``HEAD``, following a link an agent planted
|
|
251
|
+
there to any file on the host (a FIFO stalls it). Nothing here reads a
|
|
252
|
+
``HEAD`` or a reference.
|
|
253
|
+
|
|
254
|
+
- The main worktree, named as git names it: the common dir, less a
|
|
255
|
+
trailing ``/.git``.
|
|
256
|
+
- Each entry ``<common>/worktrees/<id>`` that is a real directory with
|
|
257
|
+
a regular ``gitdir`` file (:func:`_read_pointer`), at the path that
|
|
258
|
+
file names less a trailing ``/.git``, absolute or relative to the
|
|
259
|
+
entry.
|
|
260
|
+
- Left out, as git marks it ``prunable``: an entry whose ``gitdir``
|
|
261
|
+
names a ``.git`` that is not there, unless the entry is ``locked``.
|
|
262
|
+
Both are ``lstat``-ed, so a link there counts as there and is not
|
|
263
|
+
followed.
|
|
264
|
+
|
|
265
|
+
An entry that is a link, or whose ``gitdir`` is not a regular file,
|
|
266
|
+
names no root, since git would read through it; the binding
|
|
267
|
+
(:func:`bound_admin_dir`) refuses such a worktree too. A registry that
|
|
268
|
+
is a link, is not a directory, cannot be listed, or holds more than
|
|
269
|
+
:data:`MAX_REGISTRY_ENTRIES` entries, is a :class:`VerificationError`
|
|
270
|
+
(:func:`_registry_entries`).
|
|
271
|
+
"""
|
|
272
|
+
roots = {Path(os.path.realpath(_less_dotgit(os.path.realpath(common)) or "/"))}
|
|
273
|
+
registry = common / "worktrees"
|
|
274
|
+
|
|
275
|
+
def refuse(what: str) -> VerificationError:
|
|
276
|
+
return VerificationError(
|
|
277
|
+
f"cannot list the repository's worktrees: {what}. The controller neither reuses an "
|
|
278
|
+
"agent worktree nor launches in a sandbox it cannot check against the registry"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
for entry in _registry_entries(registry, refuse) or (): # None: no linked worktree
|
|
282
|
+
dotgit = os.path.join(entry.admin, entry.gitdir) # an absolute value stands alone
|
|
283
|
+
if not (os.path.lexists(dotgit) or entry.locked):
|
|
284
|
+
continue # prunable: nothing at its path is a worktree
|
|
285
|
+
roots.add(Path(os.path.realpath(os.path.join(entry.admin, _less_dotgit(entry.gitdir)))))
|
|
286
|
+
return roots
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True)
|
|
290
|
+
class Candidate:
|
|
291
|
+
"""The worktree's ``HEAD`` as the controller reads it (D6.3).
|
|
292
|
+
|
|
293
|
+
``attached``: ``HEAD`` is a symbolic reference. ``head``: the commit it
|
|
294
|
+
names, "" when it is attached or names none the shared store holds.
|
|
295
|
+
``problem``: why there is no ``HEAD`` to read at all (not a regular
|
|
296
|
+
file), else "".
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
attached: bool
|
|
300
|
+
head: str
|
|
301
|
+
problem: str = ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _admin_git(
|
|
305
|
+
common: Path, args: list[str], runner: Runner | None, what: str, worktree: str
|
|
306
|
+
) -> tuple[int, str]:
|
|
307
|
+
res = _run(
|
|
308
|
+
runner,
|
|
309
|
+
local_git_request(["--git-dir", str(common), *args], timeout_seconds=GIT_TIMEOUT_SECONDS),
|
|
310
|
+
)
|
|
311
|
+
if res.timed_out or res.truncated or res.exit_code not in (0, 1):
|
|
312
|
+
raise VerificationError(
|
|
313
|
+
f"cannot read {what} of the agent worktree {worktree} (`git {' '.join(args)}` "
|
|
314
|
+
f"{_ended(res)}{_detail(res)}); nothing was pushed. 'resume' reads it again"
|
|
315
|
+
)
|
|
316
|
+
return res.exit_code, res.stdout.strip()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def head_problem(common: Path, admin: Path) -> str | None:
|
|
320
|
+
"""Why git may not read the admin dir's ``HEAD``, or ``None`` (D6.3).
|
|
321
|
+
|
|
322
|
+
``lstat``, no link followed: anything but a regular file is named,
|
|
323
|
+
since git follows a link there (to a file the agent wrote outside the
|
|
324
|
+
store, invisible inside) and reads the commit it names. So is a link or
|
|
325
|
+
another file type at the admin dir or the registry above it
|
|
326
|
+
(:func:`_root_problem`), which git would read ``worktrees/<id>/HEAD``
|
|
327
|
+
through (#223). Checked before ``HEAD`` is read at all, by the
|
|
328
|
+
candidate read and by the import.
|
|
329
|
+
"""
|
|
330
|
+
problem = _root_problem(common, admin)
|
|
331
|
+
if problem:
|
|
332
|
+
return problem
|
|
333
|
+
head_file = admin / "HEAD"
|
|
334
|
+
try:
|
|
335
|
+
mode = os.lstat(head_file).st_mode
|
|
336
|
+
except OSError as exc:
|
|
337
|
+
return f"{head_file} cannot be read ({exc.strerror})"
|
|
338
|
+
if not stat.S_ISREG(mode):
|
|
339
|
+
return f"{head_file} is not a regular file"
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_candidate(common: Path, admin: Path, worktree: str, runner: Runner | None) -> Candidate:
|
|
344
|
+
"""The candidate: ``worktrees/<id>/HEAD`` addressed through the common dir (D6.3).
|
|
345
|
+
|
|
346
|
+
Whether ``HEAD`` is attached is asked with ``symbolic-ref --no-recurse``,
|
|
347
|
+
which reads ``HEAD`` alone: without it git resolves the reference
|
|
348
|
+
``HEAD`` names, and so a link the agent planted at
|
|
349
|
+
``worktrees/<id>/refs/...`` to any file on the host (#223). An
|
|
350
|
+
inconclusive git read is a :class:`VerificationError`, never a guess.
|
|
351
|
+
"""
|
|
352
|
+
problem = head_problem(common, admin)
|
|
353
|
+
if problem:
|
|
354
|
+
return Candidate(False, "", problem)
|
|
355
|
+
ref = f"worktrees/{admin.name}/HEAD"
|
|
356
|
+
attached, _ = _admin_git(
|
|
357
|
+
common,
|
|
358
|
+
["symbolic-ref", "-q", "--no-recurse", ref],
|
|
359
|
+
runner,
|
|
360
|
+
"whether HEAD is detached",
|
|
361
|
+
worktree,
|
|
362
|
+
)
|
|
363
|
+
if attached == 0:
|
|
364
|
+
# Never a candidate: the reference it names (one the agent may have
|
|
365
|
+
# written in its admin dir) is neither opened nor resolved.
|
|
366
|
+
return Candidate(True, "")
|
|
367
|
+
found, head = _admin_git(
|
|
368
|
+
common,
|
|
369
|
+
["rev-parse", "--verify", "-q", f"{ref}^{{commit}}"],
|
|
370
|
+
runner,
|
|
371
|
+
"the HEAD commit",
|
|
372
|
+
worktree,
|
|
373
|
+
)
|
|
374
|
+
if found != 0 or not _SHA_RE.fullmatch(head):
|
|
375
|
+
head = ""
|
|
376
|
+
return Candidate(False, head)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _root_problem(common: Path, directory: Path) -> str | None:
|
|
380
|
+
"""Why ``directory`` is not a real directory below ``common``, or ``None``.
|
|
381
|
+
|
|
382
|
+
Every component from ``common`` down to ``directory`` is ``lstat``-ed:
|
|
383
|
+
one that is a link, not a directory, or cannot be checked is named, so
|
|
384
|
+
nothing is read through it.
|
|
385
|
+
"""
|
|
386
|
+
try:
|
|
387
|
+
parts = directory.relative_to(common).parts
|
|
388
|
+
except ValueError:
|
|
389
|
+
parts = ()
|
|
390
|
+
if not parts or ".." in parts:
|
|
391
|
+
return f"{directory} is not a directory below the git common dir {common}"
|
|
392
|
+
path = common
|
|
393
|
+
for part in parts:
|
|
394
|
+
path = path / part
|
|
395
|
+
try:
|
|
396
|
+
mode = os.lstat(path).st_mode
|
|
397
|
+
except OSError as exc:
|
|
398
|
+
return f"{path} cannot be checked ({exc.strerror})"
|
|
399
|
+
if stat.S_ISLNK(mode):
|
|
400
|
+
return f"{path} is a link"
|
|
401
|
+
if not stat.S_ISDIR(mode):
|
|
402
|
+
return f"{path} is not a directory"
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def private_objects_problem(common: Path, private: Path) -> str | None:
|
|
407
|
+
"""Why the private object directory may not be read, or ``None`` (D6.5).
|
|
408
|
+
|
|
409
|
+
Walked without following a link. The directory itself, and each one
|
|
410
|
+
between the git common dir ``common`` and it, must be a real directory:
|
|
411
|
+
a link, another file type or one that cannot be checked is named, never
|
|
412
|
+
taken for an empty store. Inside it, an alternates file, a link, a
|
|
413
|
+
socket, a FIFO, a block device or a character device other than an
|
|
414
|
+
overlay whiteout (0:0, skipped) is named; nothing is opened.
|
|
415
|
+
"""
|
|
416
|
+
problem = _root_problem(common, private)
|
|
417
|
+
if problem:
|
|
418
|
+
return problem
|
|
419
|
+
for name in ALTERNATES_FILES:
|
|
420
|
+
try:
|
|
421
|
+
os.lstat(private / name)
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
continue
|
|
424
|
+
except OSError as exc:
|
|
425
|
+
return f"{private / name} cannot be checked ({exc.strerror})"
|
|
426
|
+
return f"{private / name} exists: it would make git read another object store"
|
|
427
|
+
stack = [private]
|
|
428
|
+
seen = 0
|
|
429
|
+
while stack:
|
|
430
|
+
directory = stack.pop()
|
|
431
|
+
try:
|
|
432
|
+
with os.scandir(directory) as entries:
|
|
433
|
+
for entry in entries:
|
|
434
|
+
seen += 1
|
|
435
|
+
if seen > MAX_PRIVATE_ENTRIES:
|
|
436
|
+
return f"{private} holds more than {MAX_PRIVATE_ENTRIES} entries"
|
|
437
|
+
st = entry.stat(follow_symlinks=False)
|
|
438
|
+
if stat.S_ISDIR(st.st_mode):
|
|
439
|
+
stack.append(Path(entry.path))
|
|
440
|
+
elif stat.S_ISREG(st.st_mode):
|
|
441
|
+
continue
|
|
442
|
+
elif stat.S_ISCHR(st.st_mode) and st.st_rdev == 0:
|
|
443
|
+
continue # an overlay whiteout: a shared object hidden inside
|
|
444
|
+
else:
|
|
445
|
+
return f"{entry.path} is neither a directory, a regular file nor a whiteout"
|
|
446
|
+
except FileNotFoundError:
|
|
447
|
+
continue
|
|
448
|
+
except OSError as exc:
|
|
449
|
+
return f"{directory} cannot be walked ({exc.strerror})"
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def holds_objects(private: Path) -> bool:
|
|
454
|
+
"""Whether the private object directory has anything to import or refuse.
|
|
455
|
+
|
|
456
|
+
``False`` only when it is absent, or a real directory (``lstat``, no
|
|
457
|
+
link followed) without an entry. A link, another file type, or a
|
|
458
|
+
directory that cannot be checked or listed is ``True``, so that the
|
|
459
|
+
import's walk (:func:`private_objects_problem`) refuses it rather than
|
|
460
|
+
taking it for an empty store.
|
|
461
|
+
"""
|
|
462
|
+
try:
|
|
463
|
+
if not stat.S_ISDIR(os.lstat(private).st_mode):
|
|
464
|
+
return True
|
|
465
|
+
with os.scandir(private) as entries:
|
|
466
|
+
return any(True for _ in entries)
|
|
467
|
+
except FileNotFoundError:
|
|
468
|
+
return False
|
|
469
|
+
except OSError:
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def alternate_entry(directory: Path) -> str:
|
|
474
|
+
"""``directory`` as the one entry of ``GIT_ALTERNATE_OBJECT_DIRECTORIES`` (#224).
|
|
475
|
+
|
|
476
|
+
Git splits that variable on ``:`` unless an entry starts with ``"``,
|
|
477
|
+
which it then unquotes C-style. So the entry is always double-quoted,
|
|
478
|
+
with ``\\`` and ``"`` escaped; every other byte, ``:`` included, passes
|
|
479
|
+
verbatim. Unquoted, a path with a ``:`` names two directories that do
|
|
480
|
+
not exist, git reads no object through either, and the import would
|
|
481
|
+
take the agent's commit for none.
|
|
482
|
+
"""
|
|
483
|
+
escaped = str(directory).replace("\\", "\\\\").replace('"', '\\"')
|
|
484
|
+
return f'"{escaped}"'
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class ObjectImportError(VerificationError):
|
|
488
|
+
"""The strict import did not complete; nothing was published."""
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def import_private_objects(
|
|
492
|
+
common: Path,
|
|
493
|
+
admin: Path,
|
|
494
|
+
private: Path,
|
|
495
|
+
runner: Runner | None,
|
|
496
|
+
*,
|
|
497
|
+
exclude: tuple[str, ...] = (),
|
|
498
|
+
git_lock: Callable[[str], AbstractContextManager[object]] | None = None,
|
|
499
|
+
) -> str:
|
|
500
|
+
"""Import the objects ``HEAD`` reaches that the shared store lacks (D6.5).
|
|
501
|
+
|
|
502
|
+
Returns the ``HEAD`` commit the import covered ("" when the directory
|
|
503
|
+
holds nothing, or ``HEAD`` names no commit even with it). The walk of
|
|
504
|
+
:func:`private_objects_problem` comes first: tampering is a
|
|
505
|
+
:class:`SandboxTamperError`. A ``HEAD`` that is not a regular file
|
|
506
|
+
(:func:`head_problem`, the check :func:`read_candidate` makes) is an
|
|
507
|
+
:class:`ObjectImportError` before git reads anything: nothing is packed
|
|
508
|
+
or imported, and the candidate read refuses the candidate with it. The
|
|
509
|
+
commit is the object id ``HEAD`` holds, read without following a link;
|
|
510
|
+
an attached ``HEAD`` imports nothing (it is never a candidate). Then
|
|
511
|
+
``rev-list --objects <head> --not --branches --tags --remotes
|
|
512
|
+
<exclude>`` with the private directory as an alternate, the objects the
|
|
513
|
+
shared store does not hold, ``pack-objects`` of exactly those, and
|
|
514
|
+
``index-pack --stdin --fix-thin --strict`` into the shared store.
|
|
515
|
+
Idempotent; a failure is :class:`ObjectImportError` and is not retried
|
|
516
|
+
here. ``exclude`` are commits the shared store holds (the phase's
|
|
517
|
+
recorded base), so the walk stops there.
|
|
518
|
+
|
|
519
|
+
Only ``index-pack`` writes the shared store: it runs under the
|
|
520
|
+
shared-Git lock (L1, ADR 0008 D12.1) that ``git_lock(operation)``
|
|
521
|
+
returns, by default :class:`~autoforge.locking.SharedGitLock` on
|
|
522
|
+
``common``, and with automatic maintenance off (D12.3). The walk,
|
|
523
|
+
``rev-list`` and ``pack-objects`` read the store and write only a
|
|
524
|
+
private scratch directory. A lock not obtained within its bound is this
|
|
525
|
+
import failing (:class:`ObjectImportError`, D11.3).
|
|
526
|
+
"""
|
|
527
|
+
if not holds_objects(private):
|
|
528
|
+
return ""
|
|
529
|
+
problem = private_objects_problem(common, private)
|
|
530
|
+
if problem:
|
|
531
|
+
raise SandboxTamperError(
|
|
532
|
+
f"the private object directory {private} of the agent worktree has been "
|
|
533
|
+
f"tampered with: {problem}. Nothing was read from it and nothing was published; "
|
|
534
|
+
"inspect it, and remove what does not belong there before unblocking the run"
|
|
535
|
+
)
|
|
536
|
+
problem = head_problem(common, admin)
|
|
537
|
+
if problem:
|
|
538
|
+
raise ObjectImportError(
|
|
539
|
+
f"the strict import of the agent's commits from {private} did not start: "
|
|
540
|
+
f"{problem}, and git would follow it; nothing was read or published"
|
|
541
|
+
)
|
|
542
|
+
alternate = {"GIT_ALTERNATE_OBJECT_DIRECTORIES": alternate_entry(private)}
|
|
543
|
+
git_dir = ["--git-dir", str(common)]
|
|
544
|
+
|
|
545
|
+
def git(
|
|
546
|
+
args: list[str],
|
|
547
|
+
what: str,
|
|
548
|
+
*,
|
|
549
|
+
env: dict[str, str] | None = None,
|
|
550
|
+
stdin: bytes | None = None,
|
|
551
|
+
allow: tuple[int, ...] = (0,),
|
|
552
|
+
) -> ExecutionResult:
|
|
553
|
+
request = local_git_request(
|
|
554
|
+
[*git_dir, *args], env=env, timeout_seconds=IMPORT_TIMEOUT_SECONDS
|
|
555
|
+
)
|
|
556
|
+
request.stdin_data = stdin
|
|
557
|
+
res = _run(runner, request)
|
|
558
|
+
if res.timed_out or res.truncated or res.exit_code not in allow:
|
|
559
|
+
raise ObjectImportError(
|
|
560
|
+
f"the strict import of the agent's commits from {private} failed: {what} "
|
|
561
|
+
f"{_ended(res)}{_detail(res)}; nothing was published"
|
|
562
|
+
)
|
|
563
|
+
return res
|
|
564
|
+
|
|
565
|
+
detached = _read_pointer(admin / "HEAD")
|
|
566
|
+
if detached is None or not _SHA_RE.fullmatch(detached):
|
|
567
|
+
# Attached (``ref: ...``), or no object id: no candidate to cover,
|
|
568
|
+
# and no reference the agent wrote is resolved. The candidate read
|
|
569
|
+
# says why there is none.
|
|
570
|
+
return ""
|
|
571
|
+
found = git(
|
|
572
|
+
["rev-parse", "--verify", "-q", f"{detached}^{{commit}}"],
|
|
573
|
+
"reading HEAD",
|
|
574
|
+
env=alternate,
|
|
575
|
+
allow=(0, 1),
|
|
576
|
+
)
|
|
577
|
+
head = found.stdout.strip()
|
|
578
|
+
if found.exit_code != 0 or not _SHA_RE.fullmatch(head):
|
|
579
|
+
return ""
|
|
580
|
+
present = _present(git, [head, *exclude])
|
|
581
|
+
if head in present:
|
|
582
|
+
return head
|
|
583
|
+
listed = git(
|
|
584
|
+
[
|
|
585
|
+
"rev-list",
|
|
586
|
+
"--objects",
|
|
587
|
+
head,
|
|
588
|
+
"--not",
|
|
589
|
+
"--branches",
|
|
590
|
+
"--tags",
|
|
591
|
+
"--remotes",
|
|
592
|
+
*[sha for sha in exclude if sha in present],
|
|
593
|
+
],
|
|
594
|
+
"listing the objects HEAD reaches",
|
|
595
|
+
env=alternate,
|
|
596
|
+
)
|
|
597
|
+
lines = [line for line in listed.stdout.splitlines() if line.strip()]
|
|
598
|
+
ids = [line.split(" ", 1)[0] for line in lines]
|
|
599
|
+
missing = set(ids) - _present(git, ids)
|
|
600
|
+
wanted = [line for line in lines if line.split(" ", 1)[0] in missing]
|
|
601
|
+
if not wanted:
|
|
602
|
+
return head
|
|
603
|
+
scratch = tempfile.mkdtemp(prefix="autoforge-import-")
|
|
604
|
+
try:
|
|
605
|
+
base = os.path.join(scratch, "import")
|
|
606
|
+
git(
|
|
607
|
+
["pack-objects", "-q", base],
|
|
608
|
+
"packing the agent's objects",
|
|
609
|
+
env=alternate,
|
|
610
|
+
stdin=("\n".join(wanted) + "\n").encode(),
|
|
611
|
+
)
|
|
612
|
+
packs = [name for name in os.listdir(scratch) if name.endswith(".pack")]
|
|
613
|
+
if len(packs) != 1:
|
|
614
|
+
raise ObjectImportError(
|
|
615
|
+
f"the strict import of the agent's commits from {private} failed: "
|
|
616
|
+
f"pack-objects wrote {len(packs)} packs; nothing was published"
|
|
617
|
+
)
|
|
618
|
+
with open(os.path.join(scratch, packs[0]), "rb") as handle:
|
|
619
|
+
data = handle.read()
|
|
620
|
+
lock = git_lock or (lambda operation: SharedGitLock(common, operation=operation))
|
|
621
|
+
try:
|
|
622
|
+
with lock("import the agent's objects into the shared store"):
|
|
623
|
+
git(
|
|
624
|
+
[*NO_AUTO_MAINTENANCE, "index-pack", "--stdin", "--fix-thin", "--strict"],
|
|
625
|
+
"indexing the pack strictly into the shared store",
|
|
626
|
+
stdin=data,
|
|
627
|
+
)
|
|
628
|
+
except (GitLockTimeout, LockError) as exc:
|
|
629
|
+
raise ObjectImportError(
|
|
630
|
+
f"the strict import of the agent's commits from {private} failed: {exc}; "
|
|
631
|
+
"nothing was published"
|
|
632
|
+
) from exc
|
|
633
|
+
finally:
|
|
634
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
635
|
+
if head not in _present(git, [head]):
|
|
636
|
+
raise ObjectImportError(
|
|
637
|
+
f"the strict import of the agent's commits from {private} left HEAD {head} "
|
|
638
|
+
"outside the shared object store; nothing was published"
|
|
639
|
+
)
|
|
640
|
+
return head
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _present(git: Callable[..., ExecutionResult], ids: list[str]) -> set[str]:
|
|
644
|
+
"""Which of ``ids`` the shared store itself holds (no alternate)."""
|
|
645
|
+
unique = list(dict.fromkeys(i for i in ids if _SHA_RE.fullmatch(i)))
|
|
646
|
+
if not unique:
|
|
647
|
+
return set()
|
|
648
|
+
res = git(
|
|
649
|
+
["cat-file", "--batch-check=%(objectname)"],
|
|
650
|
+
"checking which objects the shared store holds",
|
|
651
|
+
stdin=("\n".join(unique) + "\n").encode(),
|
|
652
|
+
)
|
|
653
|
+
return {line.strip() for line in res.stdout.splitlines() if _SHA_RE.fullmatch(line.strip())}
|