agent-bios 0.14.0 → 0.15.0
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.
- package/claude/guides/claude-prompting.md +59 -7
- package/claude/guides/gpt-prompting.md +60 -4
- package/codex/guides/claude-prompting.md +59 -7
- package/codex/guides/gpt-prompting.md +60 -4
- package/compose/corpus-state.py +1170 -0
- package/compose/write-update-cache.py +53 -0
- package/install.sh +111 -6
- package/launch/agent-launch.py +112 -6
- package/launch/i18n/en.toml +1 -0
- package/launch/i18n/ja.toml +1 -0
- package/launch/i18n/ko.toml +1 -0
- package/package.json +4 -2
- package/provenance.json +1 -1
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Corpus-version state: project status for the launcher, list, and rollback.
|
|
3
|
+
|
|
4
|
+
Corpus versions are CONTENT versions of the instruction corpus (globals,
|
|
5
|
+
guides, hooks), distinct from deployment/system versions. Git is the content
|
|
6
|
+
store: design/session-distill/versions.json maps each closed mining window
|
|
7
|
+
to the repo commit whose corpus reflects it. Rollback materializes that
|
|
8
|
+
commit, runs the commit's OWN assembler against the live domain selection,
|
|
9
|
+
and deploys the corpus files plus the two assembled surfaces the agent
|
|
10
|
+
actually reads — the Claude bundle and the Codex central region; the system
|
|
11
|
+
(launcher, wrappers, scripts) stays at its current deployment.
|
|
12
|
+
|
|
13
|
+
Subcommands:
|
|
14
|
+
project write ~/.local/share/agent-bios/corpus-status.json from the
|
|
15
|
+
repo ledger + versions registry (called by agent-bios install)
|
|
16
|
+
list print registered corpus versions
|
|
17
|
+
rollback --version V [--dry-run]: deploy the corpus as of that version
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import datetime
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import contextlib
|
|
24
|
+
import fcntl
|
|
25
|
+
import os
|
|
26
|
+
import pathlib
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tarfile
|
|
31
|
+
import tempfile
|
|
32
|
+
import uuid
|
|
33
|
+
|
|
34
|
+
# compose/ travels as one directory — checkout and npm package alike — so the sibling
|
|
35
|
+
# assembler is present wherever this script is, and it owns the payload's one
|
|
36
|
+
# temp-write + os.replace primitive. Imported rather than copied: a second copy is a
|
|
37
|
+
# second thing that can disagree, and gates/ code is not importable from shipped code.
|
|
38
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
39
|
+
from assemble import MARK_END, MARK_START, merge_codex, replace_atomically # noqa: E402
|
|
40
|
+
|
|
41
|
+
STATE_DIR = pathlib.Path.home() / ".local/share/agent-bios"
|
|
42
|
+
STATUS = pathlib.Path(
|
|
43
|
+
os.environ.get("AGENT_BIOS_CORPUS_STATUS", str(STATE_DIR / "corpus-status.json"))
|
|
44
|
+
)
|
|
45
|
+
CLAUDE_DIR = pathlib.Path(os.environ.get("CLAUDE_CONFIG_DIR", str(pathlib.Path.home() / ".claude")))
|
|
46
|
+
CODEX_DIR = pathlib.Path(os.environ.get("CODEX_HOME", str(pathlib.Path.home() / ".codex")))
|
|
47
|
+
|
|
48
|
+
# Managed corpus paths (repo-relative) and their deploy roots. The ko/
|
|
49
|
+
# tree is repo-only reference; wrappers and agent templates are system, not
|
|
50
|
+
# corpus content.
|
|
51
|
+
#
|
|
52
|
+
# The entry files are deliberately absent. `claude/CLAUDE.md` is seeded once and is the
|
|
53
|
+
# USER'S thereafter, and `codex/AGENTS.md` is ours only between the central markers — so a
|
|
54
|
+
# whole-file write of either is not a rollback, it is overwriting somebody's file. They used
|
|
55
|
+
# to be here, from the era when full mode wrote the corpus into the entry and the entry was
|
|
56
|
+
# therefore ours. compose/assemble.py owns both surfaces now — which is why rollback runs
|
|
57
|
+
# the target commit's assembler (`_assemble_at`) instead of touching them from here: the
|
|
58
|
+
# Claude entry stays the user's, and the Codex region moves only between the markers.
|
|
59
|
+
#
|
|
60
|
+
# Guides and hooks deploy under `central/`, which is where the assembler writes and where the
|
|
61
|
+
# corpus is read from. The pre-unification paths (`<claude>/guides`, `<claude>/hooks`) are
|
|
62
|
+
# swept by the installer but never read, so writing there rolls nothing back.
|
|
63
|
+
CORPUS = [
|
|
64
|
+
("claude/guides/", lambda p: CLAUDE_DIR / "central" / "guides" / pathlib.Path(p).name),
|
|
65
|
+
("claude/hooks/", lambda p: CLAUDE_DIR / "central" / "hooks" / pathlib.Path(p).name),
|
|
66
|
+
("codex/guides/", lambda p: CODEX_DIR / "guides" / pathlib.Path(p).name),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def repo_root(explicit: str | None) -> pathlib.Path:
|
|
71
|
+
if explicit:
|
|
72
|
+
return pathlib.Path(explicit).resolve()
|
|
73
|
+
return pathlib.Path(__file__).resolve().parents[1]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def git(repo: pathlib.Path, *args: str) -> str:
|
|
77
|
+
return subprocess.run(
|
|
78
|
+
["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
|
|
79
|
+
).stdout
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def deploy_target(rel: str) -> pathlib.Path | None:
|
|
83
|
+
for prefix, to in CORPUS:
|
|
84
|
+
if rel == prefix or (prefix.endswith("/") and rel.startswith(prefix)):
|
|
85
|
+
return to(rel)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def corpus_files(repo: pathlib.Path, ref: str) -> list[str]:
|
|
90
|
+
paths = [p.rstrip("/") for p, _ in CORPUS]
|
|
91
|
+
out = git(repo, "ls-tree", "-r", "--name-only", ref, "--", *paths)
|
|
92
|
+
return [line for line in out.splitlines() if line]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# The mining registries are author-side and deliberately outside `package.json` files[]:
|
|
96
|
+
# they carry curation history, and an npm rollback would still lack the git history it
|
|
97
|
+
# needs. So a packaged install has the DOMAIN half of this projection and not the VERSION
|
|
98
|
+
# half, and the two must not fail together. `None` means "this install cannot know", which
|
|
99
|
+
# is not `[]`/`{}` ("known, and empty") — the same distinction `domains_projection` already
|
|
100
|
+
# draws for `applied`. Consumers must render None as unavailable rather than as zero;
|
|
101
|
+
# `_corpus_summary_lines` in the launcher keys on `versions is None` for exactly that.
|
|
102
|
+
def load_versions(repo: pathlib.Path) -> dict | None:
|
|
103
|
+
path = repo / "design/session-distill/versions.json"
|
|
104
|
+
if not path.is_file():
|
|
105
|
+
return None
|
|
106
|
+
return json.loads(path.read_text())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def require_versions(repo: pathlib.Path, command: str) -> list | None:
|
|
110
|
+
"""For the commands that are ABOUT versions. They cannot degrade — a rollback with no
|
|
111
|
+
version registry and no git history has nothing to roll back to — so they refuse by
|
|
112
|
+
name instead of dying on a FileNotFoundError the caller has to decode."""
|
|
113
|
+
doc = load_versions(repo)
|
|
114
|
+
if doc is None:
|
|
115
|
+
print(f"corpus-state {command}: the corpus version registry is not part of a "
|
|
116
|
+
f"packaged install, and a rollback needs the repository history as well — "
|
|
117
|
+
f"run this from a checkout", file=sys.stderr)
|
|
118
|
+
return None
|
|
119
|
+
return doc["versions"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ledger_summary(repo: pathlib.Path) -> dict | None:
|
|
123
|
+
path = repo / "design/session-distill/ledger.json"
|
|
124
|
+
if not path.is_file():
|
|
125
|
+
return None
|
|
126
|
+
ledger = json.loads(path.read_text())
|
|
127
|
+
entries = ledger["entries"]
|
|
128
|
+
by_status: dict[str, int] = {}
|
|
129
|
+
by_layer: dict[str, int] = {}
|
|
130
|
+
for e in entries:
|
|
131
|
+
by_status[e["status"]] = by_status.get(e["status"], 0) + 1
|
|
132
|
+
if e["status"] == "placed":
|
|
133
|
+
layer = (e.get("classification") or {}).get("layer") or "?"
|
|
134
|
+
by_layer[layer] = by_layer.get(layer, 0) + 1
|
|
135
|
+
return {"entries": len(entries), "by_status": by_status, "placed_by_layer": by_layer}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def domains_projection(repo: pathlib.Path) -> dict:
|
|
139
|
+
"""Available domains from the manifest, applied selection from the state dir.
|
|
140
|
+
|
|
141
|
+
The launcher's corpus checklist reads BOTH from here rather than from repo
|
|
142
|
+
paths it cannot know: the installer owns this file, and every install run
|
|
143
|
+
rewrites the projection, so a stale list is impossible without a stale
|
|
144
|
+
install. `applied` is null (never []) when no selection was ever assembled —
|
|
145
|
+
"nothing chosen yet" and "core+infra only" must not read the same."""
|
|
146
|
+
available = sorted(
|
|
147
|
+
json.loads((repo / "compose" / "domains.json").read_text())["domains"]
|
|
148
|
+
)
|
|
149
|
+
applied = None
|
|
150
|
+
selection = STATE_DIR / "selection.json"
|
|
151
|
+
if selection.is_file():
|
|
152
|
+
try:
|
|
153
|
+
applied = sorted(json.loads(selection.read_text())["domains"])
|
|
154
|
+
except (ValueError, KeyError):
|
|
155
|
+
applied = None
|
|
156
|
+
return {"available": available, "applied": applied}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_project(args: argparse.Namespace) -> int:
|
|
160
|
+
repo = repo_root(args.repo)
|
|
161
|
+
versions_doc = load_versions(repo)
|
|
162
|
+
versions = versions_doc["versions"] if versions_doc is not None else None
|
|
163
|
+
current = STATUS_current_override = None
|
|
164
|
+
last_apply = None
|
|
165
|
+
deployed_corpus = None
|
|
166
|
+
# Read and write under one lock: unlocked, a concurrent record-apply or
|
|
167
|
+
# rollback landing between this read and the write below is reverted whole.
|
|
168
|
+
with _status_lock():
|
|
169
|
+
if STATUS.is_file():
|
|
170
|
+
try:
|
|
171
|
+
prior = json.load(STATUS.open())
|
|
172
|
+
STATUS_current_override = prior.get("rolled_back_to")
|
|
173
|
+
# The apply outcome is recorded by `record-apply` at the end of an
|
|
174
|
+
# onboard run; a reprojection must carry it, not erase it.
|
|
175
|
+
last_apply = prior.get("last_apply")
|
|
176
|
+
# What rollback last deployed; the next rollback's removal operand.
|
|
177
|
+
deployed_corpus = prior.get("deployed_corpus")
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
# Quarantined, never discarded: an unreadable status is where the
|
|
180
|
+
# rollback/apply record LIVES, and a projection that shrugs over it
|
|
181
|
+
# replaces "the corpus is at v1" with "the corpus is at latest"
|
|
182
|
+
# without a word. The bytes move aside so a person can still read
|
|
183
|
+
# what the record held, and the loss is reported, not silent.
|
|
184
|
+
quarantine = STATUS.with_name(
|
|
185
|
+
STATUS.name
|
|
186
|
+
+ f".corrupt-{datetime.datetime.now():%Y%m%d-%H%M%S}"
|
|
187
|
+
)
|
|
188
|
+
os.replace(STATUS, quarantine)
|
|
189
|
+
print(
|
|
190
|
+
f"corpus status at {STATUS} was unreadable ({exc}); the damaged "
|
|
191
|
+
f"file is quarantined at {quarantine}, and any rollback/apply "
|
|
192
|
+
"state it held could not be carried into this projection",
|
|
193
|
+
file=sys.stderr,
|
|
194
|
+
)
|
|
195
|
+
latest = versions[-1]["version"] if versions else None
|
|
196
|
+
current = STATUS_current_override or latest
|
|
197
|
+
status = {
|
|
198
|
+
"repo": str(repo),
|
|
199
|
+
"current_version": current,
|
|
200
|
+
"latest_version": latest,
|
|
201
|
+
"rolled_back_to": STATUS_current_override,
|
|
202
|
+
"versions": versions,
|
|
203
|
+
"summary": ledger_summary(repo),
|
|
204
|
+
"domains": domains_projection(repo),
|
|
205
|
+
"last_apply": last_apply,
|
|
206
|
+
"deployed_corpus": deployed_corpus,
|
|
207
|
+
"generated": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
208
|
+
}
|
|
209
|
+
_write_status(status)
|
|
210
|
+
print(f"corpus-status written: {STATUS} (current={current}, latest={latest})")
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# What a rollback target must carry to be deployable. The live corpus is `central/bundle.md`,
|
|
215
|
+
# assembled from these two, so a commit without them cannot produce one — the guides would move
|
|
216
|
+
# and the bundle, which is what the agent reads, would stay. Asked in one place because `list`
|
|
217
|
+
# has to advertise exactly what `rollback` will accept.
|
|
218
|
+
ASSEMBLY_PARTS = ("compose/assemble.py", "compose/domains.json")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def deployable(repo: pathlib.Path, commit: str) -> str | None:
|
|
222
|
+
"""None if the commit can be rolled back to, else the part that makes it impossible."""
|
|
223
|
+
for part in ASSEMBLY_PARTS:
|
|
224
|
+
if subprocess.run(["git", "-C", str(repo), "cat-file", "-e", f"{commit}:{part}"],
|
|
225
|
+
capture_output=True).returncode != 0:
|
|
226
|
+
return part
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
231
|
+
repo = repo_root(args.repo)
|
|
232
|
+
versions = require_versions(repo, "list")
|
|
233
|
+
if versions is None:
|
|
234
|
+
return 2
|
|
235
|
+
usable = 0
|
|
236
|
+
for v in versions:
|
|
237
|
+
blocker = deployable(repo, v["commit"])
|
|
238
|
+
usable += blocker is None
|
|
239
|
+
note = "" if blocker is None else f" [UNAVAILABLE: predates {blocker}]"
|
|
240
|
+
print(f"{v['version']} commit={v['commit'][:12]} closed={v['closed']} "
|
|
241
|
+
f"{v.get('summary', '')}{note}")
|
|
242
|
+
# Listing targets that all refuse is how a recovery path looks available while being gone.
|
|
243
|
+
if versions and not usable:
|
|
244
|
+
print("\nNo registered version can be rolled back to: every one predates the assembled "
|
|
245
|
+
"layout. Register a corpus version from a commit that carries "
|
|
246
|
+
f"{' and '.join(ASSEMBLY_PARTS)} before relying on this.", file=sys.stderr)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _applied_selection() -> tuple[list[str] | None, str]:
|
|
251
|
+
"""The live domain selection, or (None, why) — the assembly cannot run blind.
|
|
252
|
+
|
|
253
|
+
Read from the installer-owned projection rather than asked: a rollback that
|
|
254
|
+
guessed a selection would assemble a bundle nobody chose."""
|
|
255
|
+
selection = STATE_DIR / "selection.json"
|
|
256
|
+
if not selection.is_file():
|
|
257
|
+
return None, f"no applied domain selection at {selection}; run onboarding first"
|
|
258
|
+
try:
|
|
259
|
+
return sorted(json.loads(selection.read_text())["domains"]), ""
|
|
260
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
261
|
+
return None, f"unreadable domain selection at {selection} ({exc})"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _deployed_previously(repo: pathlib.Path, versions: list[dict]) -> tuple[set[str] | None, str]:
|
|
265
|
+
"""The managed corpus set the live homes hold NOW, or (None, why it is unknowable).
|
|
266
|
+
|
|
267
|
+
HEAD is only the answer while the deployment tracks HEAD. After a rollback it
|
|
268
|
+
does not, and deriving removals from HEAD is exactly how rolling forward left
|
|
269
|
+
a file that only the rolled-back version deploys. Preference order: the
|
|
270
|
+
manifest the last rollback recorded; else the recorded rollback version's own
|
|
271
|
+
commit; else HEAD. A state that names a version the registry no longer
|
|
272
|
+
carries is refused rather than guessed around."""
|
|
273
|
+
prior = None
|
|
274
|
+
if STATUS.is_file():
|
|
275
|
+
try:
|
|
276
|
+
prior = json.loads(STATUS.read_text())
|
|
277
|
+
except ValueError as exc:
|
|
278
|
+
return None, (f"cannot establish the deployed corpus: the status at "
|
|
279
|
+
f"{STATUS} is unreadable ({exc}); run `project` first "
|
|
280
|
+
"(it quarantines the damaged file)")
|
|
281
|
+
if isinstance(prior, dict):
|
|
282
|
+
deployed = prior.get("deployed_corpus")
|
|
283
|
+
if isinstance(deployed, dict):
|
|
284
|
+
files = deployed.get("files")
|
|
285
|
+
if isinstance(files, list) and all(isinstance(f, str) for f in files):
|
|
286
|
+
return set(files), ""
|
|
287
|
+
return None, ("cannot establish the deployed corpus: the recorded "
|
|
288
|
+
"deployed_corpus manifest is malformed")
|
|
289
|
+
rolled = prior.get("rolled_back_to")
|
|
290
|
+
if isinstance(rolled, str) and rolled:
|
|
291
|
+
match = [v for v in versions if v["version"] == rolled]
|
|
292
|
+
if not match:
|
|
293
|
+
return None, (f"cannot establish the deployed corpus: the live corpus "
|
|
294
|
+
f"is version {rolled}, which the registry no longer carries")
|
|
295
|
+
return set(corpus_files(repo, match[0]["commit"])), ""
|
|
296
|
+
return set(corpus_files(repo, "HEAD")), ""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _assemble_at(repo: pathlib.Path, commit: str, domains: list[str]) -> tuple[bytes, str]:
|
|
300
|
+
"""Assemble the corpus AS OF a commit, in a sandbox; nothing live is touched.
|
|
301
|
+
|
|
302
|
+
The commit's own assemble.py runs against its own tree — `deployable` already
|
|
303
|
+
holds targets to carrying one, and the current assembler has never been asked
|
|
304
|
+
about that corpus — with the LIVE selection, into empty sandbox homes. Returns
|
|
305
|
+
the two things rollback takes from here: the Claude bundle's bytes, and the
|
|
306
|
+
codex CENTRAL TEXT extracted from between the sandbox's markers. The region,
|
|
307
|
+
never the merged file: publication merges it into AGENTS.md as that file
|
|
308
|
+
exists AT PUBLICATION, so an edit the user lands while this runs rides
|
|
309
|
+
through instead of being overwritten by a stale snapshot. Raises RuntimeError
|
|
310
|
+
with the assembler's own words on any failure, before a single live write."""
|
|
311
|
+
root = pathlib.Path(tempfile.mkdtemp(prefix="corpus-rollback-assemble-"))
|
|
312
|
+
try:
|
|
313
|
+
src = root / "tree"
|
|
314
|
+
src.mkdir()
|
|
315
|
+
archive = subprocess.run(["git", "-C", str(repo), "archive", commit],
|
|
316
|
+
check=True, capture_output=True)
|
|
317
|
+
with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar:
|
|
318
|
+
try:
|
|
319
|
+
tar.extractall(src, filter="data")
|
|
320
|
+
except TypeError: # Python < 3.12: no filter parameter
|
|
321
|
+
tar.extractall(src)
|
|
322
|
+
claude = root / "claude-home"
|
|
323
|
+
codex = root / "codex-home"
|
|
324
|
+
state = root / "state-home"
|
|
325
|
+
for directory in (claude, codex, state):
|
|
326
|
+
directory.mkdir()
|
|
327
|
+
run = subprocess.run(
|
|
328
|
+
[sys.executable, str(src / "compose" / "assemble.py"),
|
|
329
|
+
"--domains", ",".join(domains),
|
|
330
|
+
"--claude-dir", str(claude), "--codex-dir", str(codex),
|
|
331
|
+
"--state-dir", str(state)],
|
|
332
|
+
capture_output=True, text=True)
|
|
333
|
+
if run.returncode != 0:
|
|
334
|
+
tail = (run.stdout + run.stderr).strip().splitlines()[-3:]
|
|
335
|
+
raise RuntimeError(
|
|
336
|
+
f"the assembler at {commit[:9]} failed: {' | '.join(tail) or 'no output'}")
|
|
337
|
+
bundle = claude / "central" / "bundle.md"
|
|
338
|
+
agents = codex / "AGENTS.md"
|
|
339
|
+
for produced in (bundle, agents):
|
|
340
|
+
if not produced.is_file():
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
f"the assembler at {commit[:9]} reported success but produced "
|
|
343
|
+
f"no {produced.name}")
|
|
344
|
+
agents_text = agents.read_text(encoding="utf-8")
|
|
345
|
+
if MARK_START not in agents_text or MARK_END not in agents_text:
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
f"the assembler at {commit[:9]} produced an AGENTS.md without the "
|
|
348
|
+
"owned marker region")
|
|
349
|
+
central = agents_text.split(MARK_START, 1)[1].split(MARK_END, 1)[0]
|
|
350
|
+
# merge_codex writes MARK_START + "\n" + central_text; give it back
|
|
351
|
+
# exactly what it will re-wrap.
|
|
352
|
+
central = central[1:] if central.startswith("\n") else central
|
|
353
|
+
return bundle.read_bytes(), central
|
|
354
|
+
finally:
|
|
355
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_rollback(args: argparse.Namespace) -> int:
|
|
359
|
+
repo = repo_root(args.repo)
|
|
360
|
+
versions = require_versions(repo, "rollback")
|
|
361
|
+
if versions is None:
|
|
362
|
+
return 2
|
|
363
|
+
match = [v for v in versions if v["version"] == args.version]
|
|
364
|
+
if not match:
|
|
365
|
+
known = ", ".join(v["version"] for v in versions)
|
|
366
|
+
print(f"unknown corpus version: {args.version} (known: {known})", file=sys.stderr)
|
|
367
|
+
return 2
|
|
368
|
+
commit = match[0]["commit"]
|
|
369
|
+
blocker = deployable(repo, commit)
|
|
370
|
+
if blocker is not None:
|
|
371
|
+
print(f"refusing rollback to {args.version}: that corpus predates the assembled layout "
|
|
372
|
+
f"({blocker} is absent at {commit[:9]}), so its bundle cannot be rebuilt and only "
|
|
373
|
+
"part of the corpus would move. `list` marks which versions are available.",
|
|
374
|
+
file=sys.stderr)
|
|
375
|
+
return 2
|
|
376
|
+
# One deployment at a time, held from target/backup calculation through the
|
|
377
|
+
# final status update. The status lock covers only status writes; two
|
|
378
|
+
# unserialized rollbacks interleaved their file writes and BOTH reported
|
|
379
|
+
# success over a corpus split between their targets. Every input read again
|
|
380
|
+
# inside is validated inside: a wait behind another deployment is exactly
|
|
381
|
+
# when the world changes.
|
|
382
|
+
with _deploy_lock():
|
|
383
|
+
return _locked_rollback(args, repo, versions, commit)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _locked_rollback(args: argparse.Namespace, repo: pathlib.Path,
|
|
387
|
+
versions: list[dict], commit: str) -> int:
|
|
388
|
+
# Validated UNDER the lock, where it is read: a selection that vanishes while
|
|
389
|
+
# this rollback waits behind another deployment must be a named refusal, not
|
|
390
|
+
# whatever error an unchecked read escalates into.
|
|
391
|
+
domains, why = _applied_selection()
|
|
392
|
+
if domains is None:
|
|
393
|
+
print(f"refusing rollback to {args.version}: {why} — without the applied "
|
|
394
|
+
"selection the bundle for that corpus cannot be assembled.", file=sys.stderr)
|
|
395
|
+
return 2
|
|
396
|
+
target_files = corpus_files(repo, commit)
|
|
397
|
+
previous, why = _deployed_previously(repo, versions)
|
|
398
|
+
if previous is None:
|
|
399
|
+
print(f"refusing rollback to {args.version}: {why} — removals cannot be "
|
|
400
|
+
"derived, so files from the deployed version would silently survive.",
|
|
401
|
+
file=sys.stderr)
|
|
402
|
+
return 2
|
|
403
|
+
# The union: HEAD names what an install deploys, `previous` names what a
|
|
404
|
+
# rollback deployed, and files from either side that the target lacks must go.
|
|
405
|
+
removal_candidates = sorted(
|
|
406
|
+
(previous | set(corpus_files(repo, "HEAD"))) - set(target_files))
|
|
407
|
+
# Per-transaction and collision-proof: two rollbacks in one second shared a
|
|
408
|
+
# second-granularity directory and overwrote each other's undo copies.
|
|
409
|
+
backup = (STATE_DIR / "backups"
|
|
410
|
+
/ f"corpus-rollback-{datetime.datetime.now():%Y%m%d-%H%M%S}"
|
|
411
|
+
f"-{os.getpid()}-{uuid.uuid4().hex[:8]}")
|
|
412
|
+
# Assembled BEFORE any live write: an assembly that cannot run refuses the
|
|
413
|
+
# whole rollback with nothing to restore.
|
|
414
|
+
try:
|
|
415
|
+
bundle, codex_central = _assemble_at(repo, commit, domains)
|
|
416
|
+
except (RuntimeError, OSError, subprocess.CalledProcessError) as exc:
|
|
417
|
+
print(f"refusing rollback to {args.version}: {exc} — nothing was written.",
|
|
418
|
+
file=sys.stderr)
|
|
419
|
+
return 1
|
|
420
|
+
written = removed = 0
|
|
421
|
+
# Every step is recorded so it can be undone. A corpus is a SET of files that agree
|
|
422
|
+
# about which version they are: a run that stopped in the middle left nine files at
|
|
423
|
+
# the target and twenty-six at the previous one, wrote no status, and reported a raw
|
|
424
|
+
# OSError — so the record on disk went on naming the version the corpus no longer
|
|
425
|
+
# was. The backup taken a line below already holds what each write replaced, which
|
|
426
|
+
# makes putting it back the cheap half of this; saying so when even that fails is
|
|
427
|
+
# the half that matters.
|
|
428
|
+
undo: list[tuple[pathlib.Path, pathlib.Path | None]] = []
|
|
429
|
+
|
|
430
|
+
def restore() -> list[str]:
|
|
431
|
+
"""Put every applied file back. Returns the ones that could not be restored."""
|
|
432
|
+
stuck = []
|
|
433
|
+
for target, saved in reversed(undo):
|
|
434
|
+
try:
|
|
435
|
+
if saved is None:
|
|
436
|
+
target.unlink(missing_ok=True)
|
|
437
|
+
else:
|
|
438
|
+
target.write_bytes(saved.read_bytes())
|
|
439
|
+
except OSError:
|
|
440
|
+
stuck.append(str(target))
|
|
441
|
+
return stuck
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
for rel in target_files:
|
|
445
|
+
dst = deploy_target(rel)
|
|
446
|
+
if dst is None:
|
|
447
|
+
continue
|
|
448
|
+
content = subprocess.run(
|
|
449
|
+
["git", "-C", str(repo), "show", f"{commit}:{rel}"],
|
|
450
|
+
check=True, capture_output=True,
|
|
451
|
+
).stdout
|
|
452
|
+
if args.dry_run:
|
|
453
|
+
print(f"[dry-run] write {dst}")
|
|
454
|
+
continue
|
|
455
|
+
bak = None
|
|
456
|
+
if dst.is_file():
|
|
457
|
+
bak = backup / rel
|
|
458
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
bak.write_bytes(dst.read_bytes())
|
|
460
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
dst.write_bytes(content)
|
|
462
|
+
undo.append((dst, bak))
|
|
463
|
+
if rel.startswith("claude/hooks/"):
|
|
464
|
+
dst.chmod(0o755)
|
|
465
|
+
written += 1
|
|
466
|
+
for rel in removal_candidates:
|
|
467
|
+
dst = deploy_target(rel)
|
|
468
|
+
if dst is None or not dst.is_file():
|
|
469
|
+
continue
|
|
470
|
+
if args.dry_run:
|
|
471
|
+
print(f"[dry-run] remove {dst} (absent in {args.version})")
|
|
472
|
+
continue
|
|
473
|
+
bak = backup / rel
|
|
474
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
475
|
+
bak.write_bytes(dst.read_bytes())
|
|
476
|
+
dst.unlink()
|
|
477
|
+
undo.append((dst, bak))
|
|
478
|
+
removed += 1
|
|
479
|
+
# The assembled surfaces land LAST: the bundle is what the agent reads, so
|
|
480
|
+
# it names the target version only once every guide it references has.
|
|
481
|
+
bundle_dst = CLAUDE_DIR / "central" / "bundle.md"
|
|
482
|
+
if args.dry_run:
|
|
483
|
+
print(f"[dry-run] write {bundle_dst} (assembled at {args.version})")
|
|
484
|
+
else:
|
|
485
|
+
bak = None
|
|
486
|
+
if bundle_dst.is_file():
|
|
487
|
+
bak = backup / "assembled/claude-central-bundle.md"
|
|
488
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
489
|
+
bak.write_bytes(bundle_dst.read_bytes())
|
|
490
|
+
bundle_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
bundle_dst.write_bytes(bundle)
|
|
492
|
+
undo.append((bundle_dst, bak))
|
|
493
|
+
written += 1
|
|
494
|
+
codex_dst = CODEX_DIR / "AGENTS.md"
|
|
495
|
+
if args.dry_run:
|
|
496
|
+
print(f"[dry-run] merge {codex_dst} central region (assembled at {args.version})")
|
|
497
|
+
else:
|
|
498
|
+
bak = None
|
|
499
|
+
if codex_dst.is_file():
|
|
500
|
+
bak = backup / "assembled/codex-AGENTS.md"
|
|
501
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
502
|
+
bak.write_bytes(codex_dst.read_bytes())
|
|
503
|
+
# Merged into the file AS IT EXISTS NOW, through the same merge every
|
|
504
|
+
# install uses — atomic, mode-preserving, marker-bounded — never a
|
|
505
|
+
# whole-file write of a snapshot: an edit the user landed while the
|
|
506
|
+
# guides were copying rides through; only the region is ours to move.
|
|
507
|
+
merge_codex(CODEX_DIR, codex_central)
|
|
508
|
+
undo.append((codex_dst, bak))
|
|
509
|
+
written += 1
|
|
510
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
511
|
+
stuck = restore()
|
|
512
|
+
if stuck:
|
|
513
|
+
print(
|
|
514
|
+
f"rollback to {args.version} failed ({exc}) and {len(stuck)} file(s) could "
|
|
515
|
+
f"not be put back: {', '.join(stuck[:5])}. The corpus is SPLIT between "
|
|
516
|
+
f"versions; the previous content of every file this touched is at {backup}.",
|
|
517
|
+
file=sys.stderr,
|
|
518
|
+
)
|
|
519
|
+
return 1
|
|
520
|
+
print(
|
|
521
|
+
f"rollback to {args.version} failed ({exc}); every file it had already written "
|
|
522
|
+
f"was restored, so the corpus is unchanged. Nothing is split.",
|
|
523
|
+
file=sys.stderr,
|
|
524
|
+
)
|
|
525
|
+
return 1
|
|
526
|
+
if args.dry_run:
|
|
527
|
+
return 0
|
|
528
|
+
# Re-project, then mark the rollback (a plain project would report latest).
|
|
529
|
+
cmd_project(args)
|
|
530
|
+
# Sequential with cmd_project's lock, never nested inside it: flock on a
|
|
531
|
+
# second open of the same lock file would deadlock this process.
|
|
532
|
+
with _status_lock():
|
|
533
|
+
status = json.load(STATUS.open())
|
|
534
|
+
status["current_version"] = args.version
|
|
535
|
+
status["rolled_back_to"] = None if args.version == status["latest_version"] else args.version
|
|
536
|
+
# The next rollback's removal operand: exactly what is deployed now.
|
|
537
|
+
status["deployed_corpus"] = {
|
|
538
|
+
"version": args.version,
|
|
539
|
+
"files": sorted(rel for rel in target_files if deploy_target(rel) is not None),
|
|
540
|
+
}
|
|
541
|
+
_write_status(status)
|
|
542
|
+
print(
|
|
543
|
+
f"corpus rolled back to {args.version} ({commit[:12]}): "
|
|
544
|
+
f"{written} files written, {removed} removed; backup at {backup}. "
|
|
545
|
+
"System deployment (launcher, wrappers) unchanged. Roll forward by "
|
|
546
|
+
"rolling back to the latest version."
|
|
547
|
+
)
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
APPLY_OUTCOMES = ("applied", "canary_failed", "install_failed")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@contextlib.contextmanager
|
|
555
|
+
def _flock(lock_path: pathlib.Path):
|
|
556
|
+
"""Exclusive advisory lock at `lock_path`, held for the with-block."""
|
|
557
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
# O_NOFOLLOW and no truncation: a "w" open follows a planted symlink and
|
|
559
|
+
# truncates its target merely by running the command.
|
|
560
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
|
561
|
+
with os.fdopen(fd, "w") as handle:
|
|
562
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
563
|
+
yield
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _status_lock():
|
|
567
|
+
"""Exclusive advisory lock over corpus-status writes. `project` and
|
|
568
|
+
`record-apply` both read-modify-write the whole file; unlocked, whichever
|
|
569
|
+
writes second silently reverts the other's fields."""
|
|
570
|
+
return _flock(STATUS.with_name(STATUS.name + ".lock"))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _deploy_lock():
|
|
574
|
+
"""Exclusive advisory lock over corpus deployment. One rollback at a time,
|
|
575
|
+
target to status: file writes serialized only by the status lock let two
|
|
576
|
+
rollbacks both report success over a split corpus. A DIFFERENT file from the
|
|
577
|
+
status lock, deliberately — rollback takes the status lock inside this one,
|
|
578
|
+
and flock on a second open of one file deadlocks a single process."""
|
|
579
|
+
return _flock(STATUS.with_name(STATUS.name + ".deploy.lock"))
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _write_status(status: dict) -> None:
|
|
583
|
+
"""Every status write goes through the shared temp-write + os.replace: a
|
|
584
|
+
plain write_text truncates first and fills after, so an interrupted writer
|
|
585
|
+
left `{"current_version":` as the record and the next projection silently
|
|
586
|
+
replaced what it could not read."""
|
|
587
|
+
replace_atomically(STATUS, json.dumps(status, ensure_ascii=False, indent=1) + "\n")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def cmd_record_apply(args: argparse.Namespace) -> int:
|
|
591
|
+
"""Record the outcome of one onboard apply into the existing status file.
|
|
592
|
+
|
|
593
|
+
Read-modify-write of `last_apply` only: the projection owns every other
|
|
594
|
+
field, and an outcome recorded against a status that does not exist yet
|
|
595
|
+
would invent one — refuse instead, loudly."""
|
|
596
|
+
with _status_lock():
|
|
597
|
+
if not STATUS.is_file():
|
|
598
|
+
print(f"no corpus status to record into: {STATUS}", file=sys.stderr)
|
|
599
|
+
return 1
|
|
600
|
+
try:
|
|
601
|
+
status = json.loads(STATUS.read_text())
|
|
602
|
+
except ValueError as exc:
|
|
603
|
+
print(f"corpus status unreadable: {exc}", file=sys.stderr)
|
|
604
|
+
return 1
|
|
605
|
+
status["last_apply"] = {
|
|
606
|
+
"requested": sorted(d for d in args.requested.split(",") if d),
|
|
607
|
+
"outcome": args.outcome,
|
|
608
|
+
"at": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
609
|
+
"error_tail": args.error_tail or None,
|
|
610
|
+
}
|
|
611
|
+
try:
|
|
612
|
+
_write_status(status)
|
|
613
|
+
except OSError as exc:
|
|
614
|
+
print(f"cannot record last_apply ({exc}); the prior status is intact",
|
|
615
|
+
file=sys.stderr)
|
|
616
|
+
return 1
|
|
617
|
+
print(f"last_apply recorded: {args.outcome}")
|
|
618
|
+
return 0
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def self_test() -> int:
|
|
622
|
+
"""A rollback that fails partway must leave the corpus at ONE version — and a
|
|
623
|
+
clean one must move EVERY reader surface, remove what the deployed version
|
|
624
|
+
alone carried, refuse when it cannot know what is deployed, run one at a
|
|
625
|
+
time, and never leave the status file truncated.
|
|
626
|
+
|
|
627
|
+
Driven against a throwaway git repo rather than the real registry, because every
|
|
628
|
+
version registered today is UNAVAILABLE (it predates the assembled layout), so the
|
|
629
|
+
write loop is unreachable from `list` and this path would otherwise be covered by
|
|
630
|
+
nothing at all. The failing run and the clean one differ only in whether one write
|
|
631
|
+
raises — without the clean one, a rollback that wrote nothing would satisfy the
|
|
632
|
+
failing case too. The fixture's assemble.py is a runnable stand-in with the real
|
|
633
|
+
assembler's calling convention; the convention itself is pinned against the real
|
|
634
|
+
compose/assemble.py by the --help probe below.
|
|
635
|
+
"""
|
|
636
|
+
# The variables that relocate a repository — git's own list, `local_repo_env` in
|
|
637
|
+
# environment.c, the ones it clears before entering another repo. The pre-commit hook
|
|
638
|
+
# exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE so the gates read the real index
|
|
639
|
+
# against the materialised stage, and this self-test runs under it. Inherited by the
|
|
640
|
+
# fixture they make `git init` re-initialise the REAL repository — writing
|
|
641
|
+
# core.worktree = the stage directory into its config, which outlives the stage and
|
|
642
|
+
# leaves the primary worktree unable to run `git status` — and point every later
|
|
643
|
+
# command, `cmd_rollback`'s included, at that repo instead of the fixture. So the
|
|
644
|
+
# fixture is only a fixture inside this scrub, and everything that touches it runs
|
|
645
|
+
# inside it.
|
|
646
|
+
REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE",
|
|
647
|
+
"GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
648
|
+
"GIT_PREFIX", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE", "GIT_NO_REPLACE_OBJECTS",
|
|
649
|
+
"GIT_REPLACE_REF_BASE", "GIT_CONFIG")
|
|
650
|
+
|
|
651
|
+
@contextlib.contextmanager
|
|
652
|
+
def own_repo_env():
|
|
653
|
+
saved = {name: os.environ.pop(name) for name in REPO_ENV if name in os.environ}
|
|
654
|
+
try:
|
|
655
|
+
yield
|
|
656
|
+
finally:
|
|
657
|
+
os.environ.update(saved)
|
|
658
|
+
|
|
659
|
+
def git(repo, *command):
|
|
660
|
+
return subprocess.run(["git", "-C", str(repo), *command], check=True,
|
|
661
|
+
capture_output=True, text=True).stdout.strip()
|
|
662
|
+
|
|
663
|
+
MINI_ASSEMBLER = r'''#!/usr/bin/env python3
|
|
664
|
+
"""Self-test stand-in with the REAL assembler's calling convention. It does what
|
|
665
|
+
cmd_rollback relies on the assembler for: build the bundle from ITS OWN tree and
|
|
666
|
+
merge the codex AGENTS.md marker region, preserving text outside the markers."""
|
|
667
|
+
import argparse
|
|
668
|
+
import pathlib
|
|
669
|
+
ap = argparse.ArgumentParser()
|
|
670
|
+
ap.add_argument("--domains", required=True)
|
|
671
|
+
ap.add_argument("--claude-dir", required=True)
|
|
672
|
+
ap.add_argument("--codex-dir", required=True)
|
|
673
|
+
ap.add_argument("--state-dir", required=True)
|
|
674
|
+
a = ap.parse_args()
|
|
675
|
+
repo = pathlib.Path(__file__).resolve().parents[1]
|
|
676
|
+
claude = pathlib.Path(a.claude_dir)
|
|
677
|
+
codex = pathlib.Path(a.codex_dir)
|
|
678
|
+
(claude / "central").mkdir(parents=True, exist_ok=True)
|
|
679
|
+
stamp = (repo / "BUNDLE_STAMP").read_text().strip()
|
|
680
|
+
(claude / "central" / "bundle.md").write_text("BUNDLE " + stamp + "\n")
|
|
681
|
+
start = "<!-- agent-bios:central:start -->"
|
|
682
|
+
end = "<!-- agent-bios:central:end -->"
|
|
683
|
+
agents = codex / "AGENTS.md"
|
|
684
|
+
region = start + "\nCODEX " + stamp + "\n" + end + "\n"
|
|
685
|
+
if agents.is_file():
|
|
686
|
+
body = agents.read_text()
|
|
687
|
+
if start in body and end in body:
|
|
688
|
+
pre, rest = body.split(start, 1)
|
|
689
|
+
_, post = rest.split(end, 1)
|
|
690
|
+
body = pre + region + post
|
|
691
|
+
else:
|
|
692
|
+
body = region + "\n" + body
|
|
693
|
+
else:
|
|
694
|
+
codex.mkdir(parents=True, exist_ok=True)
|
|
695
|
+
body = region + "\n## Personal\n"
|
|
696
|
+
agents.write_text(body)
|
|
697
|
+
'''
|
|
698
|
+
|
|
699
|
+
def build_fixture(repo):
|
|
700
|
+
"""Two corpus commits plus the registry: t1 carries a version-only file
|
|
701
|
+
(legacy.md) that t2 deletes, and each commit's stand-in assembler stamps
|
|
702
|
+
its own bundle — so a rollback that skips assembly, or derives removals
|
|
703
|
+
from the wrong version, is visible in the files."""
|
|
704
|
+
(repo / "claude" / "guides").mkdir(parents=True)
|
|
705
|
+
for index in range(6):
|
|
706
|
+
(repo / "claude" / "guides" / f"g{index}.md").write_text(f"TARGET {index}\n")
|
|
707
|
+
(repo / "claude" / "guides" / "legacy.md").write_text("OLD ONLY\n")
|
|
708
|
+
(repo / "BUNDLE_STAMP").write_text("one\n")
|
|
709
|
+
# `deployable` refuses a commit that predates the assembled layout, and it is
|
|
710
|
+
# right to: without these the bundle cannot be rebuilt. The fixture carries a
|
|
711
|
+
# RUNNABLE assemble.py so the assembly step is REACHED — a self-test that
|
|
712
|
+
# stops at that refusal would report OK having exercised nothing.
|
|
713
|
+
(repo / "compose").mkdir(parents=True, exist_ok=True)
|
|
714
|
+
(repo / "compose" / "assemble.py").write_text(MINI_ASSEMBLER)
|
|
715
|
+
(repo / "compose" / "domains.json").write_text(json.dumps({"domains": []}) + "\n")
|
|
716
|
+
with own_repo_env():
|
|
717
|
+
git(repo, "init", "-q")
|
|
718
|
+
git(repo, "add", "-A")
|
|
719
|
+
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "old")
|
|
720
|
+
old = git(repo, "rev-parse", "HEAD")
|
|
721
|
+
for index in range(6):
|
|
722
|
+
(repo / "claude" / "guides" / f"g{index}.md").write_text(f"NEWER {index}\n")
|
|
723
|
+
(repo / "claude" / "guides" / "legacy.md").unlink()
|
|
724
|
+
(repo / "claude" / "guides" / "fresh.md").write_text("NEW ONLY\n")
|
|
725
|
+
(repo / "BUNDLE_STAMP").write_text("two\n")
|
|
726
|
+
with own_repo_env():
|
|
727
|
+
git(repo, "add", "-A")
|
|
728
|
+
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "new")
|
|
729
|
+
new = git(repo, "rev-parse", "HEAD")
|
|
730
|
+
registry = repo / "design" / "session-distill"
|
|
731
|
+
registry.mkdir(parents=True)
|
|
732
|
+
(registry / "versions.json").write_text(json.dumps({"versions": [
|
|
733
|
+
{"version": "t1", "commit": old, "closed": "2026-01-01", "summary": "old"},
|
|
734
|
+
{"version": "t2", "commit": new, "closed": "2026-01-02", "summary": "new"},
|
|
735
|
+
]}))
|
|
736
|
+
(registry / "ledger.json").write_text(json.dumps({"entries": []}))
|
|
737
|
+
with own_repo_env():
|
|
738
|
+
git(repo, "add", "-A")
|
|
739
|
+
git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "registry")
|
|
740
|
+
return old, new
|
|
741
|
+
|
|
742
|
+
problems = []
|
|
743
|
+
root = pathlib.Path(tempfile.mkdtemp(prefix="corpus-state-selftest-"))
|
|
744
|
+
try:
|
|
745
|
+
# Control for the scrub, run FIRST and under the hook's environment whether or
|
|
746
|
+
# not the hook is present: an "outer" repo stands in for the real one, GIT_DIR
|
|
747
|
+
# points at it, and building a fixture must leave it untouched — its
|
|
748
|
+
# core.worktree unset, the fixture holding its own HEAD. Without the scrub the
|
|
749
|
+
# first `git init` rewrites the outer config, which is exactly the defect.
|
|
750
|
+
outer = root / "outer"
|
|
751
|
+
outer.mkdir()
|
|
752
|
+
with own_repo_env():
|
|
753
|
+
git(outer, "init", "-q")
|
|
754
|
+
git(outer, "-c", "user.email=t@t", "-c", "user.name=t",
|
|
755
|
+
"commit", "-q", "--allow-empty", "-m", "outer")
|
|
756
|
+
stage = root / "stage"
|
|
757
|
+
stage.mkdir()
|
|
758
|
+
planted = {"GIT_DIR": str(outer / ".git"), "GIT_WORK_TREE": str(stage),
|
|
759
|
+
"GIT_INDEX_FILE": str(outer / ".git" / "index")}
|
|
760
|
+
saved_env = {name: os.environ.get(name) for name in planted}
|
|
761
|
+
os.environ.update(planted)
|
|
762
|
+
try:
|
|
763
|
+
build_fixture(root / "control")
|
|
764
|
+
except subprocess.CalledProcessError as exc:
|
|
765
|
+
# Caught rather than propagated so this reads as the defect it is: an
|
|
766
|
+
# uninsulated fixture's commands land in the outer repo and fail there.
|
|
767
|
+
problems.append(
|
|
768
|
+
"building the fixture under an exported GIT_DIR did not build one — "
|
|
769
|
+
f"`git {' '.join(exc.cmd[3:])}` failed against the wrong repository")
|
|
770
|
+
finally:
|
|
771
|
+
for name, value in saved_env.items():
|
|
772
|
+
if value is None:
|
|
773
|
+
os.environ.pop(name, None)
|
|
774
|
+
else:
|
|
775
|
+
os.environ[name] = value
|
|
776
|
+
with own_repo_env():
|
|
777
|
+
outer_worktree = subprocess.run(
|
|
778
|
+
["git", "-C", str(outer), "config", "--get", "core.worktree"],
|
|
779
|
+
capture_output=True, text=True).stdout.strip()
|
|
780
|
+
if outer_worktree:
|
|
781
|
+
problems.append(
|
|
782
|
+
"building the fixture under an exported GIT_DIR rewrote the outer repo's "
|
|
783
|
+
f"core.worktree to {outer_worktree} — the fixture is not insulated")
|
|
784
|
+
if not (root / "control" / ".git" / "HEAD").exists():
|
|
785
|
+
problems.append(
|
|
786
|
+
"building the fixture under an exported GIT_DIR left it without a "
|
|
787
|
+
"repository of its own")
|
|
788
|
+
|
|
789
|
+
repo = root / "repo"
|
|
790
|
+
build_fixture(repo)
|
|
791
|
+
|
|
792
|
+
# The calling convention the mini-assembler stands in for, pinned against
|
|
793
|
+
# the REAL assembler: if compose/assemble.py stops answering for these
|
|
794
|
+
# flags, every fixture here keeps passing while live rollbacks break.
|
|
795
|
+
real_assembler = repo_root(None) / "compose" / "assemble.py"
|
|
796
|
+
probe = subprocess.run([sys.executable, str(real_assembler), "--help"],
|
|
797
|
+
capture_output=True, text=True)
|
|
798
|
+
missing = [flag for flag in ("--domains", "--claude-dir", "--codex-dir",
|
|
799
|
+
"--state-dir")
|
|
800
|
+
if flag not in probe.stdout]
|
|
801
|
+
if probe.returncode != 0 or missing:
|
|
802
|
+
problems.append(
|
|
803
|
+
"cmd_rollback dispatches --domains/--claude-dir/--codex-dir/--state-dir "
|
|
804
|
+
"to the target commit's assemble.py, but the real assembler no longer "
|
|
805
|
+
f"answers for: {missing or probe.stderr.strip()[:120]}")
|
|
806
|
+
|
|
807
|
+
codex_seed = ("<!-- agent-bios:central:start -->\nCODEX two\n"
|
|
808
|
+
"<!-- agent-bios:central:end -->\n\n## Personal\nMY OWN CODEX LINE\n")
|
|
809
|
+
|
|
810
|
+
def attempt(fail_at):
|
|
811
|
+
home = root / f"home-{fail_at}"
|
|
812
|
+
claude = home / ".claude"
|
|
813
|
+
codex_home = home / ".codex"
|
|
814
|
+
saved_claude = globals()["CLAUDE_DIR"]
|
|
815
|
+
globals()["CLAUDE_DIR"] = claude
|
|
816
|
+
# Located through deploy_target, not by guessing the layout: the corpus root
|
|
817
|
+
# moved once already, and a fixture that seeds the wrong directory asserts
|
|
818
|
+
# that an untouched file is untouched.
|
|
819
|
+
seeded = deploy_target("claude/guides/g0.md")
|
|
820
|
+
globals()["CLAUDE_DIR"] = saved_claude
|
|
821
|
+
seeded.parent.mkdir(parents=True, exist_ok=True)
|
|
822
|
+
seeded.write_text("PREVIOUS\n")
|
|
823
|
+
(claude / "central" / "bundle.md").write_text("LIVE BUNDLE\n")
|
|
824
|
+
codex_home.mkdir(parents=True, exist_ok=True)
|
|
825
|
+
(codex_home / "AGENTS.md").write_text(codex_seed)
|
|
826
|
+
# A user-restricted mode must ride through the region merge.
|
|
827
|
+
(codex_home / "AGENTS.md").chmod(0o600)
|
|
828
|
+
counter = [0]
|
|
829
|
+
real_write = pathlib.Path.write_bytes
|
|
830
|
+
|
|
831
|
+
def flaky(self, data):
|
|
832
|
+
if str(self).startswith(str(claude)) and "backups" not in str(self):
|
|
833
|
+
counter[0] += 1
|
|
834
|
+
if counter[0] == fail_at:
|
|
835
|
+
raise OSError("self-test write failure")
|
|
836
|
+
return real_write(self, data)
|
|
837
|
+
|
|
838
|
+
real_assemble_at = globals()["_assemble_at"]
|
|
839
|
+
|
|
840
|
+
def editing_assemble_at(repo_arg, commit_arg, domains_arg):
|
|
841
|
+
# The user's edit landing in the window between the assembly
|
|
842
|
+
# snapshot and publication: the merge must carry it through,
|
|
843
|
+
# never overwrite it with the pre-assembly state of the file.
|
|
844
|
+
result = real_assemble_at(repo_arg, commit_arg, domains_arg)
|
|
845
|
+
agents = codex_home / "AGENTS.md"
|
|
846
|
+
agents.write_text(agents.read_text().replace(
|
|
847
|
+
"MY OWN CODEX LINE", "MY OWN CODEX LINE\nMID-FLIGHT EDIT"))
|
|
848
|
+
return result
|
|
849
|
+
|
|
850
|
+
saved = (globals()["CLAUDE_DIR"], globals()["CODEX_DIR"],
|
|
851
|
+
globals()["STATE_DIR"], globals()["STATUS"],
|
|
852
|
+
globals()["cmd_project"], globals()["_assemble_at"])
|
|
853
|
+
globals()["CLAUDE_DIR"] = claude
|
|
854
|
+
globals()["CODEX_DIR"] = codex_home
|
|
855
|
+
globals()["STATE_DIR"] = home / "state"
|
|
856
|
+
globals()["STATUS"] = home / "state" / "corpus-status.json"
|
|
857
|
+
# The projection reads the real ledger and is a different question; what this
|
|
858
|
+
# asserts is that the FILES end up at one version. Stubbed so the fixture does
|
|
859
|
+
# not have to carry a ledger to answer a question it is not asking.
|
|
860
|
+
(home / "state").mkdir(parents=True, exist_ok=True)
|
|
861
|
+
(home / "state" / "selection.json").write_text(
|
|
862
|
+
json.dumps({"version": 1, "domains": []}))
|
|
863
|
+
globals()["STATUS"].write_text(json.dumps(
|
|
864
|
+
{"current_version": "before", "latest_version": "t1"}))
|
|
865
|
+
globals()["cmd_project"] = lambda _args: 0
|
|
866
|
+
globals()["_assemble_at"] = editing_assemble_at
|
|
867
|
+
pathlib.Path.write_bytes = flaky
|
|
868
|
+
try:
|
|
869
|
+
code = cmd_rollback(argparse.Namespace(
|
|
870
|
+
repo=str(repo), version="t1", dry_run=False))
|
|
871
|
+
except Exception as exc: # a raise is itself the defect this asserts against
|
|
872
|
+
code = f"raised {type(exc).__name__}"
|
|
873
|
+
finally:
|
|
874
|
+
pathlib.Path.write_bytes = real_write
|
|
875
|
+
(globals()["CLAUDE_DIR"], globals()["CODEX_DIR"],
|
|
876
|
+
globals()["STATE_DIR"], globals()["STATUS"],
|
|
877
|
+
globals()["cmd_project"], globals()["_assemble_at"]) = saved
|
|
878
|
+
landed = sorted(q.name for q in seeded.parent.glob("*.md"))
|
|
879
|
+
return (code, landed, seeded.read_text(),
|
|
880
|
+
(claude / "central" / "bundle.md").read_text(),
|
|
881
|
+
(codex_home / "AGENTS.md").read_text(),
|
|
882
|
+
(codex_home / "AGENTS.md").stat().st_mode & 0o777)
|
|
883
|
+
|
|
884
|
+
# `cmd_rollback` reads the fixture through `git -C <repo>`, which the hook's
|
|
885
|
+
# GIT_DIR would redirect at the real repository — same scrub, same reason.
|
|
886
|
+
with own_repo_env():
|
|
887
|
+
code, landed, seeded, bundle_text, agents_text, agents_mode = attempt(0)
|
|
888
|
+
if code != 0 or len(landed) < 6 or seeded.strip() != "TARGET 0":
|
|
889
|
+
problems.append(
|
|
890
|
+
f"clean rollback did not apply the corpus (code={code} files={len(landed)})")
|
|
891
|
+
if "MID-FLIGHT EDIT" not in agents_text:
|
|
892
|
+
problems.append(
|
|
893
|
+
"a user edit landing between the assembly snapshot and publication "
|
|
894
|
+
"was overwritten — AGENTS.md must be merged as it exists at "
|
|
895
|
+
"publication, never replaced by a stale snapshot")
|
|
896
|
+
if agents_mode != 0o600:
|
|
897
|
+
problems.append(
|
|
898
|
+
f"publishing the codex region widened a user-restricted AGENTS.md "
|
|
899
|
+
f"from 0o600 to {oct(agents_mode)}")
|
|
900
|
+
# The surfaces the agent actually reads must move WITH the guides: the
|
|
901
|
+
# defect was a rollback that reported an older version while the bundle
|
|
902
|
+
# and the codex central region silently stayed current.
|
|
903
|
+
if bundle_text.strip() != "BUNDLE one":
|
|
904
|
+
problems.append(
|
|
905
|
+
"rollback moved the guides but left the live bundle at the current "
|
|
906
|
+
"version — the assembled surface the agent reads did not roll back")
|
|
907
|
+
if "CODEX one" not in agents_text or "CODEX two" in agents_text:
|
|
908
|
+
problems.append(
|
|
909
|
+
"rollback did not rewrite the codex AGENTS.md central region to the "
|
|
910
|
+
"target version")
|
|
911
|
+
if "MY OWN CODEX LINE" not in agents_text:
|
|
912
|
+
problems.append("rollback destroyed the user's text outside the codex markers")
|
|
913
|
+
with own_repo_env():
|
|
914
|
+
code, landed, seeded, bundle_text, agents_text, agents_mode = attempt(3)
|
|
915
|
+
if code == 0:
|
|
916
|
+
problems.append("a rollback whose write failed reported success")
|
|
917
|
+
elif not isinstance(code, int):
|
|
918
|
+
problems.append(f"a failed rollback escaped as an exception ({code})")
|
|
919
|
+
if seeded.strip() != "PREVIOUS":
|
|
920
|
+
problems.append(
|
|
921
|
+
"a failed rollback left a file at the target version — the corpus is split")
|
|
922
|
+
if bundle_text.strip() != "LIVE BUNDLE":
|
|
923
|
+
problems.append(
|
|
924
|
+
"a failed rollback moved the live bundle — the reader surface is split")
|
|
925
|
+
if "CODEX two" not in agents_text or "MY OWN CODEX LINE" not in agents_text:
|
|
926
|
+
problems.append("a failed rollback did not leave the codex surface as it was")
|
|
927
|
+
|
|
928
|
+
# ---- The real CLI end to end, subprocess-driven against one fixture. ----
|
|
929
|
+
fx_home = root / "fx-home"
|
|
930
|
+
fx_claude = root / "fx-claude"
|
|
931
|
+
fx_codex = root / "fx-codex"
|
|
932
|
+
fx_tmp = root / "fx-tmp"
|
|
933
|
+
fx_state = fx_home / ".local" / "share" / "agent-bios"
|
|
934
|
+
fx_status = fx_state / "corpus-status.json"
|
|
935
|
+
for directory in (fx_home, fx_claude, fx_codex, fx_tmp, fx_state):
|
|
936
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
937
|
+
(fx_state / "selection.json").write_text(json.dumps({"version": 1, "domains": []}))
|
|
938
|
+
guides_live = fx_claude / "central" / "guides"
|
|
939
|
+
guides_live.mkdir(parents=True)
|
|
940
|
+
for index in range(6):
|
|
941
|
+
(guides_live / f"g{index}.md").write_text(f"NEWER {index}\n")
|
|
942
|
+
(guides_live / "fresh.md").write_text("NEW ONLY\n")
|
|
943
|
+
(fx_claude / "central" / "bundle.md").write_text("BUNDLE two\n")
|
|
944
|
+
(fx_codex / "AGENTS.md").write_text(codex_seed)
|
|
945
|
+
env = {name: value for name, value in os.environ.items() if name not in REPO_ENV}
|
|
946
|
+
env.update({"HOME": str(fx_home), "TMPDIR": str(fx_tmp),
|
|
947
|
+
"CLAUDE_CONFIG_DIR": str(fx_claude), "CODEX_HOME": str(fx_codex),
|
|
948
|
+
"AGENT_BIOS_CORPUS_STATUS": str(fx_status)})
|
|
949
|
+
me = pathlib.Path(__file__).resolve()
|
|
950
|
+
|
|
951
|
+
def run_cli(*argv):
|
|
952
|
+
return subprocess.run([sys.executable, str(me), *argv, "--repo", str(repo)],
|
|
953
|
+
capture_output=True, text=True, env=env)
|
|
954
|
+
|
|
955
|
+
project = run_cli("project")
|
|
956
|
+
if project.returncode != 0:
|
|
957
|
+
problems.append(f"subprocess project failed: {project.stderr.strip()[:200]}")
|
|
958
|
+
back = run_cli("rollback", "--version", "t1")
|
|
959
|
+
legacy = guides_live / "legacy.md"
|
|
960
|
+
fresh = guides_live / "fresh.md"
|
|
961
|
+
if back.returncode != 0:
|
|
962
|
+
problems.append("subprocess rollback to t1 failed: "
|
|
963
|
+
f"{(back.stdout + back.stderr).strip()[:200]}")
|
|
964
|
+
else:
|
|
965
|
+
if not legacy.is_file():
|
|
966
|
+
problems.append(
|
|
967
|
+
"rolling back did not deploy the file only the older version carries")
|
|
968
|
+
if fresh.exists():
|
|
969
|
+
problems.append("rolling back left a file the target version does not carry")
|
|
970
|
+
forward = run_cli("rollback", "--version", "t2")
|
|
971
|
+
if forward.returncode != 0:
|
|
972
|
+
problems.append("subprocess roll-forward to t2 failed: "
|
|
973
|
+
f"{(forward.stdout + forward.stderr).strip()[:200]}")
|
|
974
|
+
else:
|
|
975
|
+
# THE removal control: the operand must be what is deployed, and a
|
|
976
|
+
# gutted removal loop must be caught by a surviving file, not by a
|
|
977
|
+
# count nobody asserts.
|
|
978
|
+
if legacy.exists():
|
|
979
|
+
problems.append(
|
|
980
|
+
"rolling forward left a file that only the rolled-back version "
|
|
981
|
+
"deploys — the removal operand ignores what is actually deployed")
|
|
982
|
+
if (not fresh.is_file()
|
|
983
|
+
or (fx_claude / "central" / "bundle.md").read_text().strip() != "BUNDLE two"):
|
|
984
|
+
problems.append("rolling forward did not restore the newer corpus and bundle")
|
|
985
|
+
state = json.loads(fx_status.read_text())
|
|
986
|
+
if state.get("current_version") != "t2" or state.get("rolled_back_to") is not None:
|
|
987
|
+
problems.append("roll-forward status does not name the target version")
|
|
988
|
+
marks = [result.stdout.partition("backup at ")[2].partition(". System")[0]
|
|
989
|
+
for result in (back, forward)]
|
|
990
|
+
if all(marks) and marks[0] == marks[1]:
|
|
991
|
+
problems.append(
|
|
992
|
+
"two rollbacks shared one backup directory — their undo copies collide")
|
|
993
|
+
|
|
994
|
+
# ---- Mutual exclusion: a held deploy lock must queue a second rollback. ----
|
|
995
|
+
deploy_lock_path = fx_status.with_name(fx_status.name + ".deploy.lock")
|
|
996
|
+
lock_fd = os.open(str(deploy_lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
|
|
997
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
998
|
+
g0_live = guides_live / "g0.md"
|
|
999
|
+
held = subprocess.Popen(
|
|
1000
|
+
[sys.executable, str(me), "rollback", "--version", "t1", "--repo", str(repo)],
|
|
1001
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
|
|
1002
|
+
try:
|
|
1003
|
+
held.wait(timeout=1.5)
|
|
1004
|
+
problems.append(
|
|
1005
|
+
"a rollback proceeded while another deployment held the deploy lock "
|
|
1006
|
+
"— concurrent rollbacks would both report success over a split corpus")
|
|
1007
|
+
except subprocess.TimeoutExpired:
|
|
1008
|
+
if g0_live.read_text() != "NEWER 0\n":
|
|
1009
|
+
problems.append("a lock-blocked rollback wrote files before holding the lock")
|
|
1010
|
+
os.close(lock_fd)
|
|
1011
|
+
try:
|
|
1012
|
+
queued = held.wait(timeout=60)
|
|
1013
|
+
except subprocess.TimeoutExpired:
|
|
1014
|
+
held.kill()
|
|
1015
|
+
held.wait()
|
|
1016
|
+
queued = None
|
|
1017
|
+
problems.append("a queued rollback never completed after the lock was released")
|
|
1018
|
+
if queued is not None and (queued != 0 or g0_live.read_text() != "TARGET 0\n"):
|
|
1019
|
+
problems.append("the queued rollback failed after the deploy lock was released")
|
|
1020
|
+
|
|
1021
|
+
# ---- A selection that vanishes while waiting is refused BY NAME. ----
|
|
1022
|
+
# The wait behind another deployment is exactly when the world changes:
|
|
1023
|
+
# unvalidated, the locked read escalated into a raw TypeError.
|
|
1024
|
+
selection_path = fx_state / "selection.json"
|
|
1025
|
+
saved_selection = selection_path.read_text()
|
|
1026
|
+
lock_fd = os.open(str(deploy_lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
|
|
1027
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
1028
|
+
vanish = subprocess.Popen(
|
|
1029
|
+
[sys.executable, str(me), "rollback", "--version", "t2", "--repo", str(repo)],
|
|
1030
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
|
|
1031
|
+
try:
|
|
1032
|
+
vanish.wait(timeout=1.0)
|
|
1033
|
+
problems.append(
|
|
1034
|
+
"a rollback proceeded while the deploy lock was held (selection leg)")
|
|
1035
|
+
except subprocess.TimeoutExpired:
|
|
1036
|
+
selection_path.unlink()
|
|
1037
|
+
os.close(lock_fd)
|
|
1038
|
+
try:
|
|
1039
|
+
vanish_out, vanish_err = vanish.communicate(timeout=60)
|
|
1040
|
+
except subprocess.TimeoutExpired:
|
|
1041
|
+
vanish.kill()
|
|
1042
|
+
vanish_out, vanish_err = vanish.communicate()
|
|
1043
|
+
problems.append("the selection-vanish rollback never completed")
|
|
1044
|
+
if vanish.returncode != 2 or "no applied domain selection" not in vanish_err:
|
|
1045
|
+
problems.append(
|
|
1046
|
+
"a selection that vanished while waiting for the deploy lock was not "
|
|
1047
|
+
f"refused by name (rc={vanish.returncode}: "
|
|
1048
|
+
f"{vanish_err.strip().splitlines()[-1][:120] if vanish_err.strip() else 'no stderr'})")
|
|
1049
|
+
if g0_live.read_text() != "TARGET 0\n":
|
|
1050
|
+
problems.append("a selection-refused rollback still moved files")
|
|
1051
|
+
selection_path.write_text(saved_selection)
|
|
1052
|
+
|
|
1053
|
+
# ---- Refusal when the deployed set cannot be established. ----
|
|
1054
|
+
fx_status.write_text(json.dumps(
|
|
1055
|
+
{"current_version": "ghost", "latest_version": "t2", "rolled_back_to": "ghost"}))
|
|
1056
|
+
snapshot = sorted(path.name for path in guides_live.glob("*.md"))
|
|
1057
|
+
refused = run_cli("rollback", "--version", "t2")
|
|
1058
|
+
if refused.returncode == 0:
|
|
1059
|
+
problems.append("a rollback with an unknowable deployed set proceeded anyway")
|
|
1060
|
+
elif "cannot establish the deployed corpus" not in refused.stderr:
|
|
1061
|
+
problems.append("the refusal does not name the unestablishable deployed set")
|
|
1062
|
+
if sorted(path.name for path in guides_live.glob("*.md")) != snapshot:
|
|
1063
|
+
problems.append("a refused rollback still moved files")
|
|
1064
|
+
|
|
1065
|
+
# ---- An unreadable status is quarantined and reported, never discarded. ----
|
|
1066
|
+
fx_status.write_bytes(b'{"current_version":')
|
|
1067
|
+
reproject = run_cli("project")
|
|
1068
|
+
quarantines = sorted(fx_state.glob("corpus-status.json.corrupt-*"))
|
|
1069
|
+
if reproject.returncode != 0:
|
|
1070
|
+
problems.append(
|
|
1071
|
+
f"project over a truncated status failed: {reproject.stderr.strip()[:200]}")
|
|
1072
|
+
else:
|
|
1073
|
+
if not quarantines or quarantines[-1].read_bytes() != b'{"current_version":':
|
|
1074
|
+
problems.append(
|
|
1075
|
+
"projecting over a truncated status silently discarded the damaged "
|
|
1076
|
+
"bytes instead of quarantining them")
|
|
1077
|
+
if "unreadable" not in reproject.stderr:
|
|
1078
|
+
problems.append("the projection did not report the unreadable status")
|
|
1079
|
+
try:
|
|
1080
|
+
json.loads(fx_status.read_text())
|
|
1081
|
+
except ValueError:
|
|
1082
|
+
problems.append("the projection left the status unreadable")
|
|
1083
|
+
|
|
1084
|
+
# ---- An interrupted status write must not truncate the record. ----
|
|
1085
|
+
ra_home = root / "record-apply"
|
|
1086
|
+
ra_home.mkdir()
|
|
1087
|
+
seeded_status = {"current_version": "t2", "latest_version": "t2",
|
|
1088
|
+
"rolled_back_to": None, "last_apply": None}
|
|
1089
|
+
saved_state = (globals()["STATE_DIR"], globals()["STATUS"])
|
|
1090
|
+
globals()["STATE_DIR"] = ra_home
|
|
1091
|
+
globals()["STATUS"] = ra_home / "corpus-status.json"
|
|
1092
|
+
globals()["STATUS"].write_text(json.dumps(seeded_status))
|
|
1093
|
+
real_write_text = pathlib.Path.write_text
|
|
1094
|
+
|
|
1095
|
+
def truncating(self, text, *wargs, **kw):
|
|
1096
|
+
# The failure a plain write_text really has: truncate, fill partway, die.
|
|
1097
|
+
if self.name.startswith("corpus-status.json"):
|
|
1098
|
+
with open(self, "w") as handle:
|
|
1099
|
+
handle.write(text[:19])
|
|
1100
|
+
raise OSError("self-test write interruption")
|
|
1101
|
+
return real_write_text(self, text, *wargs, **kw)
|
|
1102
|
+
|
|
1103
|
+
pathlib.Path.write_text = truncating
|
|
1104
|
+
try:
|
|
1105
|
+
code = cmd_record_apply(argparse.Namespace(
|
|
1106
|
+
repo=None, requested="alpha", outcome="applied", error_tail=""))
|
|
1107
|
+
except Exception as exc:
|
|
1108
|
+
code = f"raised {type(exc).__name__}"
|
|
1109
|
+
finally:
|
|
1110
|
+
pathlib.Path.write_text = real_write_text
|
|
1111
|
+
if not isinstance(code, int) or code == 0:
|
|
1112
|
+
problems.append(
|
|
1113
|
+
f"an interrupted status write did not report failure (code={code})")
|
|
1114
|
+
try:
|
|
1115
|
+
after = json.loads(globals()["STATUS"].read_text())
|
|
1116
|
+
except ValueError:
|
|
1117
|
+
after = None
|
|
1118
|
+
problems.append(
|
|
1119
|
+
"an interrupted status write left the corpus status truncated — "
|
|
1120
|
+
"the write is not atomic")
|
|
1121
|
+
if after is not None and after != seeded_status:
|
|
1122
|
+
problems.append("an interrupted status write altered the recorded status")
|
|
1123
|
+
(globals()["STATE_DIR"], globals()["STATUS"]) = saved_state
|
|
1124
|
+
except subprocess.CalledProcessError as exc:
|
|
1125
|
+
# A fixture command failing outside the control above is the same defect seen
|
|
1126
|
+
# from the main run — named here so the gate reports it instead of a traceback.
|
|
1127
|
+
problems.append(
|
|
1128
|
+
f"a git command against the fixture failed (`git {' '.join(exc.cmd[3:])}`) — "
|
|
1129
|
+
"is the fixture insulated from the caller's GIT_DIR?")
|
|
1130
|
+
finally:
|
|
1131
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
1132
|
+
|
|
1133
|
+
for problem in problems:
|
|
1134
|
+
print(f"corpus-state --self-test: FAIL: {problem}", file=sys.stderr)
|
|
1135
|
+
if problems:
|
|
1136
|
+
return 1
|
|
1137
|
+
print("corpus-state --self-test: OK (a failed rollback restores; a clean one moves "
|
|
1138
|
+
"every reader surface, removes what only the deployed version carried, runs "
|
|
1139
|
+
"one at a time, and status writes stay atomic)")
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def main() -> int:
|
|
1144
|
+
# Before argparse, because --self-test takes none of the subcommands' arguments and
|
|
1145
|
+
# every subcommand here is required.
|
|
1146
|
+
if "--self-test" in sys.argv[1:]:
|
|
1147
|
+
return self_test()
|
|
1148
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1149
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1150
|
+
commands = {
|
|
1151
|
+
name: sub.add_parser(name)
|
|
1152
|
+
for name in ("project", "list", "rollback", "record-apply")
|
|
1153
|
+
}
|
|
1154
|
+
for command in commands.values():
|
|
1155
|
+
command.add_argument("--repo", help="repo root (default: derived from this script's path)")
|
|
1156
|
+
commands["rollback"].add_argument("--version", required=True)
|
|
1157
|
+
commands["rollback"].add_argument("--dry-run", action="store_true")
|
|
1158
|
+
commands["record-apply"].add_argument("--requested", required=True,
|
|
1159
|
+
help="comma-separated domain selection")
|
|
1160
|
+
commands["record-apply"].add_argument("--outcome", required=True, choices=APPLY_OUTCOMES)
|
|
1161
|
+
commands["record-apply"].add_argument("--error-tail", default="")
|
|
1162
|
+
args = parser.parse_args()
|
|
1163
|
+
return {
|
|
1164
|
+
"project": cmd_project, "list": cmd_list, "rollback": cmd_rollback,
|
|
1165
|
+
"record-apply": cmd_record_apply,
|
|
1166
|
+
}[args.cmd](args)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
if __name__ == "__main__":
|
|
1170
|
+
raise SystemExit(main())
|