Files
odysseus/tests/test_wave3_background_followup.py
T
Alexandre Teixeira da4bf3531f Merge frozen lab b1666951 (Wave 3) into Wave 4 effects provenance
Integrates the merged and frozen Wave 3 lab commit
b1666951faf8285054e1ca90f11533b0fb53fb57 with a normal merge, preserving
every Wave 4 commit unchanged.

Conflict: src/agent_runtime/resources.py. Wave 3's _control_plane_snapshot()
/ _control_plane_path(path, *, snapshot=None) split is kept. The snapshot adds
the effect-store directories to its prefix set after the recursive job-dir
inventory and no longer references path (the auto-merged prefix check would
have raised NameError there). _control_plane_path calls _aliases_effect_store
after its os.stat, only for multiply linked files, so single-link files never
list the store.

Semantic reconciliation (no textual conflict): bg_monitor keeps launch
settlement right after the first successful validate_job and before the
authority check, with Wave 3's post-drain revalidation intact. The
deleted-session branch, terminal before linkage validation, now settles a
validated launch too: that job is later pruned and its publication retired,
which would otherwise leave its effect RUNNING. Regression tests cover the
snapshot form of the effect-store check and both deleted-session linkage
outcomes.
2026-10-03 01:13:36 +01:00

116 lines
5.5 KiB
Python

"""Permanent linkage loss suppresses continuation without granting authority."""
from types import SimpleNamespace
import time
import pytest
from src import bg_jobs, bg_monitor
from src.agent_runtime import process_resources as resources
from tests.test_background_resource_identity import store, seed
from src.agent_runtime.resources import ResourceIdentityError
@pytest.fixture
def monitor_session(monkeypatch):
messages = []
sess = SimpleNamespace(id='thread', owner='alice', model='test-model', get_context_messages=lambda: [])
sm = SimpleNamespace(get_session=lambda sid: sess, add_message=lambda *args: messages.append(args), save_sessions=lambda: None)
import src.ai_interaction as ai
monkeypatch.setattr(ai, 'get_session_manager', lambda: sm)
import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
async def drain(*args, **kwargs):
messages.append('drained')
return 'continued', []
monkeypatch.setattr(bg_monitor, '_drain_agent', drain)
return messages
@pytest.mark.parametrize('damage', ['missing', 'corrupt', 'wrong_owner', 'wrong_pid'])
async def test_invalid_linkage_is_terminal_without_message(store, monkeypatch, monitor_session, damage):
resource, rec = seed(store, status='done')
sidecar = bg_jobs._JOBS_DIR / 'job.authority.json'
if damage == 'missing': sidecar.unlink()
elif damage == 'corrupt': sidecar.write_text('{}')
else:
jobs = bg_jobs._load()
if damage == 'wrong_owner': jobs['job']['resource_identity']['owner'] = 'bob'
else: jobs['job']['pid'] = 99999
bg_jobs._save(jobs)
rec = jobs['job']
# Invalid data must not even be rendered into a synthetic result message.
monkeypatch.setattr(bg_monitor, '_background_result_message', lambda rec: pytest.fail('Invalid result rendered'))
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not monitor_session
assert not bg_jobs.pending_followups()
assert bg_jobs.peek('job')['followup_state'] == 'terminal_unfollowable'
assert not bg_jobs.peek('job').get('followed_up')
with pytest.raises(ResourceIdentityError):
resources.validate_job(resource)
async def test_busy_session_retries_then_continues(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done')
import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: True)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.RETRYABLE_LATER
assert bg_jobs.pending_followups() and not monitor_session
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.COMPLETED
assert bg_jobs.peek('job')['followed_up']
assert monitor_session and not bg_jobs.pending_followups()
async def test_terminal_record_prunes_exact_generation(store, monitor_session):
resource, rec = seed(store, status='done')
rec['ended_at'] = time.time() - bg_jobs._RETENTION_S - 10
bg_jobs._save({'job': rec})
(bg_jobs._JOBS_DIR / 'job.authority.json').unlink()
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not bg_jobs.pending_followups()
assert bg_jobs.peek('job') is None
assert not resources.launch_path(resource.generation).exists()
assert not monitor_session
def test_stale_terminal_snapshot_cannot_suppress_new_generation(store):
_, old = seed(store, status='done')
new, _ = seed(store, status='done')
assert not bg_jobs.mark_unfollowable('job', expected_record=old)
assert 'followup_state' not in bg_jobs.peek('job')
assert resources.launch_path(new.generation).exists()
async def test_linkage_lost_during_continuation_cannot_deliver(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done')
async def interrupted(*args, **kwargs):
(bg_jobs._JOBS_DIR / 'job.authority.json').unlink()
return 'must not be delivered', []
monkeypatch.setattr(bg_monitor, '_drain_agent', interrupted)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not monitor_session
assert not bg_jobs.pending_followups()
async def test_stale_terminal_outcome_retries_current_record(store, monkeypatch, monitor_session):
_, old = seed(store, status='done')
seed(store, status='done')
async def terminal(rec): return bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
monkeypatch.setattr(bg_monitor, '_run_followup', terminal)
assert await bg_monitor._process_followup(old) is bg_monitor.FollowupResult.RETRYABLE_LATER
assert bg_jobs.pending_followups()
@pytest.mark.parametrize('linkage', ['valid', 'damaged'])
async def test_deleted_session_settles_only_a_validated_launch(store, monkeypatch, monitor_session, linkage):
"""Wave 4: a job retired for a deleted session must not leave its launch effect RUNNING."""
resource, rec = seed(store, status='done')
if linkage == 'damaged':
(bg_jobs._JOBS_DIR / 'job.authority.json').write_text('{}')
import src.ai_interaction as ai
monkeypatch.setattr(ai, 'get_session_manager', lambda: SimpleNamespace(get_session=lambda sid: None))
settled = []
monkeypatch.setattr(bg_monitor, '_settle_launch_effect', lambda job, record: settled.append(job))
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert settled == ([resource] if linkage == 'valid' else [])
assert not monitor_session