test_handoffs.py python
342 lines 16.4 KB
Raw
sha256:c87cf2c2f03da507f66f93922cb339a2cbebb33ad6372a6039032fb09a20da12 Reconcile reviewed Overseer recovery d47291d5d9030de5ebef71… Human minor ⚠ breaking 8 days ago
1 import argparse
2 import json
3 import os
4 from pathlib import Path
5 import shutil
6 import subprocess
7 from unittest.mock import patch
8
9 import pytest
10 import yaml
11
12 from cli import v1
13 from cli.v1_io import Refusal, digest
14 from cli.v1_revision import muse_read, revision
15 from tests.v1.conftest import KIT, command, seed, status
16 from tests.v1.test_adoption import adoption_args
17
18
19 def init(root, python, *args):
20 result = command('-C', root, 'init', '--vcs', 'muse', '--muse-python', python, *args)
21 assert result.returncode == 0, result.stderr
22 return status(root)
23
24
25 def commit(root, muse, text='first'):
26 (root / 'app.py').write_text('value = ' + repr(text) + '\n')
27 muse(root, 'code', 'add', '.')
28 muse(root, 'commit', '-m', text, '--json')
29 return muse(root, 'rev-parse', 'HEAD').stdout.strip()
30
31
32 def publish(root, **overrides):
33 cfg = yaml.safe_load((root / v1.CONFIG).read_bytes())
34 state = revision(root, cfg)
35 (root / '.overseer/local').mkdir(exist_ok=True)
36 (root / '.overseer/local/prompt.txt').write_text('Meaningful fixture task.\n')
37 values = dict(repo_id=cfg['repo']['id'], branch=state.branch, base_head=state.head,
38 vcs=state.kind, lane=cfg['context']['lane'], model=cfg['context']['model'],
39 action_id='SAME-ACTION', action_kind='implement',
40 expect_next=digest((root / v1.NEXT).read_bytes()), prompt_file='.overseer/local/prompt.txt')
41 values.update(overrides)
42 return command('-C', root, 'next-write', *[v for k, value in values.items()
43 if value is not None for v in ('--'+k.replace('_', '-'), value)])
44
45
46 def inventory(root):
47 return {str(p.relative_to(root)): (digest(p.read_bytes()), p.stat().st_mode)
48 for p in root.rglob('*') if p.is_file() and not p.is_symlink()}
49
50
51 def test_muse_only_unborn_commit_freshness_and_offline_readonly(muse_root, muse_python, muse):
52 root = muse_root
53 initial = init(root, muse_python)
54 assert initial['head'] == 'unborn' and initial['vcs'] == 'muse'
55 head = commit(root, muse)
56 r = command('-C', root, 'next')
57 assert r.returncode == 2 and 'next_stale_head' in r.stderr and not r.stdout
58 assert publish(root).returncode == 0
59 (root / '.muse/.muse-tmp-preserve').write_text('native CLI startup would clean this')
60 (root / '.muse/bridge-hooks.toml').write_text('[hooks]\npre_export="touch SHOULD_NOT_RUN"\n')
61 # Invalid network endpoints and env overrides must be irrelevant to local reads.
62 env = dict(HTTP_PROXY='http://127.0.0.1:1', HTTPS_PROXY='http://127.0.0.1:1',
63 MUSE_REPO_ROOT='/wrong', MUSE_WORKTREE='/wrong', PYTHONPATH='/wrong')
64 before = inventory(root)
65 for cmd in ('status', 'next'):
66 r = command('-C', root, cmd, '--json', env=env)
67 assert r.returncode == 0, r.stderr
68 assert json.loads(r.stdout)['head'] == head
69 assert json.loads(r.stdout)['publication'] == 'not_checked_offline'
70 assert inventory(root) == before
71 assert not (root / 'SHOULD_NOT_RUN').exists()
72 assert status(root)['dirty'] is False
73 (root / 'app.py').write_text('edited but not committed\n')
74 assert status(root)['dirty'] is True
75 assert command('-C', root, 'next').returncode == 0
76
77
78 def test_mixed_authority_uses_only_muse_revision(muse_root, muse_python, muse):
79 root = muse_root
80 subprocess.run(['/usr/bin/git', '-C', str(root), 'init', '-b', 'git-main'], check=True, capture_output=True)
81 init(root, muse_python)
82 commit(root, muse)
83 assert publish(root).returncode == 0
84 subprocess.run(['/usr/bin/git', '-C', str(root), '-c', 'user.name=Fixture', '-c',
85 '[email protected]', '-c', 'core.hooksPath=/dev/null',
86 'commit', '--allow-empty', '-m', 'unrelated Git revision'], check=True, capture_output=True)
87 assert status(root)['branch'] == 'main'
88 assert command('-C', root, 'next').returncode == 0
89 commit(root, muse, 'second')
90 r = command('-C', root, 'next')
91 assert r.returncode == 2 and 'stale_head' in r.stderr
92
93
94 def test_native_linked_worktree_own_branch_and_checkout_identity(muse_root, muse_python, muse, tmp_path):
95 root = muse_root
96 init(root, muse_python)
97 commit(root, muse)
98 assert publish(root).returncode == 0
99 linked = (tmp_path / 'linked').resolve()
100 muse(root, 'worktree', 'add', 'linked', 'main', '-b', 'lane-two', '--path', str(linked), '--json')
101 original = inventory(root)
102 other = init(linked, muse_python, '--lane', 'architecture')
103 assert other['branch'] == 'lane-two'
104 assert other['source_id'] == status(root)['source_id']
105 assert other['repository']['id'] != status(root)['repository']['id']
106 assert status(linked)['dirty'] is False
107 assert publish(linked).returncode == 0
108 assert command('next', cwd=linked, executable=linked/'.overseer/bin/ok').returncode == 0
109 assert inventory(root) == original
110 # Advance the shared primary branch. The linked lane's ref stays current.
111 commit(root, muse, 'primary advanced')
112 assert command('-C', linked, 'next').returncode == 0
113 assert command('-C', root, 'next').returncode != 0
114
115
116 @pytest.mark.parametrize('field,value', [('lane', 'wrong'), ('model', 'Other'),
117 ('repo_id', 'wrong'), ('branch', 'wrong'), ('base_head', 'sha256:'+'0'*64),
118 ('vcs', 'git'), ('vcs', None), ('base_head', None)])
119 def test_muse_writer_rejects_wrong_context_without_writes(muse_root, muse_python, field, value):
120 init(muse_root, muse_python)
121 before = (muse_root / v1.NEXT).read_bytes()
122 r = publish(muse_root, **{field:value})
123 assert r.returncode == 2 and not r.stdout
124 assert (muse_root / v1.NEXT).read_bytes() == before
125
126
127 @pytest.mark.parametrize('binding', [v1.CONFIG, v1.NEXT, '.overseer/bin/ok'])
128 def test_copied_muse_bindings_refused(muse_root, muse_python, muse, tmp_path, binding):
129 init(muse_root, muse_python)
130 other = (tmp_path / 'same-name').resolve()
131 other.mkdir()
132 muse(other, 'init', '--domain', 'code', '--json')
133 init(other, muse_python)
134 shutil.copyfile(muse_root/binding, other/binding)
135 r = command('next', cwd=other, executable=other/'.overseer/bin/ok')
136 assert r.returncode != 0 and not r.stdout
137
138
139 @pytest.mark.parametrize('broken', ['missing_python', 'runtime_pin', 'missing_object', 'malformed_head', 'detached', 'bad_ref', 'wrong_source', 'unregistered_pointer'])
140 def test_muse_missing_or_malformed_state_refuses(muse_root, muse_python, muse, broken):
141 root = muse_root
142 init(root, muse_python)
143 head = commit(root, muse)
144 assert publish(root).returncode == 0
145 cfg_path = root / v1.CONFIG
146 cfg = yaml.safe_load(cfg_path.read_bytes())
147 if broken == 'missing_python':
148 cfg['muse']['runtime']['python'] = str(muse_python.parent/'missing-python')
149 elif broken == 'runtime_pin':
150 cfg['muse']['runtime']['sha256'] = '0'*64
151 elif broken == 'wrong_source':
152 cfg['muse']['repo_id'] = 'sha256:'+'0'*64
153 elif broken == 'missing_object':
154 from cli.v1_revision import muse_read
155 # Delete only a disposable fixture commit object, retaining its ref.
156 matches = [p for p in (root/'.muse/objects').rglob('*') if p.is_file()
157 and (p.parent.name+p.name) == head.split(':')[1]]
158 assert len(matches) == 1
159 matches[0].unlink()
160 elif broken == 'malformed_head':
161 (root/'.muse/HEAD').write_text('unparseable')
162 elif broken == 'detached':
163 (root/'.muse/HEAD').write_text('commit: '+head+'\n')
164 elif broken == 'bad_ref':
165 (root/'.muse/refs/heads/main').write_text('not-an-oid')
166 else:
167 store = root.with_name('unregistered-store')
168 shutil.move(root/'.muse', store)
169 (root/'.muse').write_text('musestore: '+str(store)+'\n')
170 cfg_path.write_text(yaml.safe_dump(cfg))
171 before = inventory(root)
172 for cmd in ('status', 'next'):
173 r = command('-C', root, cmd)
174 assert r.returncode == 2 and not r.stdout
175 assert inventory(root) == before
176
177
178 def test_changed_revision_during_write_refused(muse_root, muse_python):
179 root = muse_root
180 init(root, muse_python)
181 cfg = yaml.safe_load((root/v1.CONFIG).read_bytes())
182 state = revision(root, cfg)
183 from dataclasses import replace
184 args = argparse.Namespace(action_id='STALE', action_kind='implement', vcs='muse', base_head=state.head)
185 before = (root/v1.NEXT).read_bytes()
186 with patch('cli.v1.revision', side_effect=[state, replace(state, head='sha256:'+'1'*64)]):
187 with pytest.raises(Refusal, match='context_changed'):
188 v1.write_next(root, cfg, args, 'Task.\n', expected=digest(before))
189 assert (root/v1.NEXT).read_bytes() == before
190
191
192 def test_explicit_adoption_preserves_identity_docs_edits_and_rollback(muse_root, muse_python, muse):
193 root = muse_root
194 subprocess.run(['/usr/bin/git', '-C', str(root), 'init', '-b', 'main'], check=True, capture_output=True)
195 assert command('-C', root, 'init', '--vcs', 'git').returncode == 0
196 commit(root, muse)
197 (root/'app.py').write_text('owner uncommitted edit\n')
198 (root/'untracked.txt').write_bytes(b'owner untracked\x00')
199 before = inventory(root)
200 original = (root/v1.CONFIG).read_bytes()
201 state = muse_read(root, str(muse_python))
202 args = adoption_args(root, vcs='muse', muse_python=str(muse_python), base_head=state['head'])
203 plan = command('-C', root, 'adopt', *args, '--dry-run', '--json')
204 assert plan.returncode == 0, plan.stderr
205 assert inventory(root) == before
206 backup = json.loads(plan.stdout)['backup']
207 assert command('-C', root, 'adopt', *args).returncode == 0
208 assert (root/backup).read_bytes() == original
209 assert yaml.safe_load((root/v1.CONFIG).read_bytes())['repo'] == yaml.safe_load(original)['repo']
210 assert command('-C', root, 'next').returncode == 2
211 assert publish(root).returncode == 0
212 assert status(root)['vcs'] == 'muse'
213 # Roll back even when the selected Muse runtime is unavailable.
214 cfg = yaml.safe_load((root/v1.CONFIG).read_bytes())
215 cfg['muse']['runtime']['python'] = str(muse_python.parent/'missing-python')
216 (root/v1.CONFIG).write_text(yaml.safe_dump(cfg))
217 args = adoption_args(root, vcs=None, restore_config=backup)
218 assert command('-C', root, 'adopt', *args).returncode == 0
219 assert (root/v1.CONFIG).read_bytes() == original
220 # The Muse NEXT is preserved and therefore rejected until Git next-write.
221 assert command('-C', root, 'next').returncode == 2
222 assert publish(root).returncode == 0
223 assert status(root)['vcs'] == 'git'
224 after = inventory(root)
225 for path in before:
226 if path not in (v1.NEXT, '.gitignore', '.museignore'):
227 assert before[path] == after[path], path
228
229
230 def test_tracked_muse_binding_blocks_adoption_and_snapshot_excludes_new_local_files(muse_root, muse_python, muse):
231 root = muse_root
232 # Existing tracked NEXT requires explicit source cleanup, not an ignore-only promise.
233 (root/'docs').mkdir()
234 (root/v1.NEXT).write_text('old imported local binding\n')
235 commit(root, muse)
236 (root/v1.NEXT).unlink()
237 r = command('-C', root, 'init', '--vcs', 'muse', '--muse-python', muse_python)
238 assert r.returncode == 2 and 'tracked_local_bindings' in r.stderr
239 # Explicit fixture cleanup creates a new snapshot; history remains intact.
240 muse(root, 'code', 'add', '.')
241 muse(root, 'commit', '-m', 'remove tracked local binding', '--json')
242 init(root, muse_python)
243 (root/'.overseer/local').mkdir()
244 (root/'.overseer/local/config-backup.yaml').write_text('local backup fixture\n')
245 (root/'.cursor/hooks').mkdir(parents=True)
246 (root/'.cursor/hooks/local.sh').write_text('#!/bin/sh\nexit 0\n')
247 (root/'templates').mkdir()
248 (root/'templates/NEXT.template.md').write_text('portable template\n')
249 commit(root, muse, 'safe snapshot')
250 files = json.loads(muse(root, 'ls-files', '--json').stdout)['files']
251 paths = [item['path'] for item in files]
252 assert not any(v1.local_path(p) for p in paths)
253 assert 'templates/NEXT.template.md' in paths
254 assert 'docs/ROADMAP.md' in paths and 'docs/OVERSEER-HANDOVER.md' in paths
255
256
257 def test_committed_git_baseline_import_isolated_from_owner_edits(tmp_path, muse, muse_python):
258 source = seed(tmp_path/'git-source')
259 assert command('-C', source, 'init').returncode == 0
260 (source/'app.py').write_text('committed = True\n')
261 for args in (('add', '.'), ('-c', 'core.hooksPath=/dev/null', 'commit', '-m', 'reviewed baseline')):
262 subprocess.run(['/usr/bin/git', '-C', str(source), *args], check=True, capture_output=True)
263 (source/'app.py').write_text('owner dirty edit\n')
264 (source/'untracked.txt').write_text('owner untracked edit\n')
265 before = inventory(source)
266 imported = (tmp_path/'isolated-import').resolve()
267 imported.mkdir()
268 muse(imported, 'bridge', 'git-import', str(source), '--target', str(imported), '--branch', 'main', '--json')
269 # Import creates history, not a populated working tree. Materialize only
270 # the reviewed imported manifest in this disposable destination.
271 manifest = json.loads(muse(imported, 'ls-files', '--json').stdout)['files']
272 paths = [item['path'] for item in manifest]
273 committed = subprocess.check_output(['/usr/bin/git', '-C', str(source), 'ls-files', '-z']).decode().rstrip('\0').split('\0')
274 assert sorted(paths) == sorted(committed)
275 muse(imported, 'restore', '--source', 'main', *paths, '--json')
276 for path in paths:
277 assert (imported/path).read_bytes() == subprocess.check_output(
278 ['/usr/bin/git', '-C', str(source), 'show', 'HEAD:'+path])
279 assert (imported/'app.py').read_text() == 'committed = True\n'
280 assert not (imported/'untracked.txt').exists()
281 assert not (imported/v1.CONFIG).exists() and not (imported/v1.NEXT).exists()
282 state = init(imported, muse_python)
283 assert state['repository']['id'] != yaml.safe_load((source/v1.CONFIG).read_bytes())['repo']['id']
284 assert command('-C', imported, 'next').returncode == 0
285 assert inventory(source) == before
286
287
288 def test_muse_concurrent_cas_and_foreign_lane_read(muse_root, muse_python):
289 root = muse_root
290 state = init(root, muse_python)
291 (root/'.overseer/local').mkdir()
292 for name in ('a', 'b'):
293 (root/'.overseer/local'/name).write_text('Concurrent task '+name+'\n')
294 args = [str(KIT/'cli/ok'), '-C', str(root), 'next-write', '--repo-id', state['repository']['id'],
295 '--branch', state['branch'], '--base-head', state['head'], '--vcs', 'muse',
296 '--lane', 'product', '--model', 'GPT-6 Astra', '--action-id', 'RACE',
297 '--action-kind', 'implement', '--expect-next', state['next_sha256']]
298 workers = [subprocess.Popen(args+['--prompt-file', '.overseer/local/'+name],
299 stdout=subprocess.PIPE, stderr=subprocess.PIPE) for name in ('a','b')]
300 for proc in workers:
301 proc.communicate(timeout=20)
302 assert sorted(proc.returncode for proc in workers) == [0, 2]
303 assert command('-C', root, 'next', '--lane', 'other').returncode == 2
304 assert command('-C', root, 'next', '--vcs', 'git').returncode == 2
305 assert command('-C', root, 'next').returncode == 0
306
307
308 def test_legacy_stage_is_not_silently_deleted_by_read(muse_root, muse_python):
309 init(muse_root, muse_python)
310 stage = muse_root/'.muse/code/stage.json'
311 stage.parent.mkdir(exist_ok=True)
312 stage.write_bytes(b'\x81legacy stage must survive')
313 before = inventory(muse_root)
314 assert command('-C', muse_root, 'status').returncode == 2
315 assert inventory(muse_root) == before
316
317
318 def test_muse_branch_change_invalidates_next(muse_root, muse_python, muse):
319 init(muse_root, muse_python)
320 commit(muse_root, muse)
321 assert publish(muse_root).returncode == 0
322 muse(muse_root, 'checkout', '-b', 'other-branch', '--json')
323 r = command('-C', muse_root, 'next')
324 assert r.returncode == 2 and 'branch_mismatch' in r.stderr and not r.stdout
325
326
327 def test_adoption_reports_existing_muse_snapshot_binding(muse_root, muse_python, muse):
328 root = muse_root
329 init(root, muse_python)
330 ignores = (root/'.museignore').read_bytes()
331 (root/'.museignore').write_text('[global]\npatterns=[]\n')
332 muse(root, 'code', 'add', 'docs/NEXT.md')
333 muse(root, 'commit', '-m', 'fixture legacy tracked binding', '--json')
334 (root/'.museignore').write_bytes(ignores)
335 state = muse_read(root, str(muse_python))
336 args = adoption_args(root, vcs='muse', muse_python=str(muse_python), base_head=state['head'])
337 before = inventory(root)
338 r = command('-C', root, 'adopt', *args, '--dry-run', '--json')
339 assert r.returncode == 1, r.stderr
340 assert json.loads(r.stdout)['tracked_local']['muse'] == ['docs/NEXT.md']
341 assert command('-C', root, 'adopt', *args).returncode == 2
342 assert inventory(root) == before
File History 1 commit
sha256:c87cf2c2f03da507f66f93922cb339a2cbebb33ad6372a6039032fb09a20da12 Reconcile reviewed Overseer recovery d47291d5d9030de5ebef71… Human minor ⚠ 8 days ago