test_handoffs.py
python
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