diff --git a/src/agent_runtime/process_resources.py b/src/agent_runtime/process_resources.py index fd2cd05d8..9c4b9f053 100644 --- a/src/agent_runtime/process_resources.py +++ b/src/agent_runtime/process_resources.py @@ -452,8 +452,10 @@ def prune_foreground_publications(): A dead/replaced manager cannot resume attachment. A missing receipt also makes attachment impossible; publication cannot reconstruct that receipt. - Its process tree still belongs to containment recovery; deleting a publication never signals or - asserts tree death. Live/unverifiable managers and background history stay. + Its process tree still belongs to containment recovery; deleting a + publication never signals or asserts tree death. Live/unverifiable managers + retain publication even after child teardown: attachment may still need it. + Background history stays intact. """ from src import containment from src import process_ownership @@ -471,13 +473,16 @@ def prune_foreground_publications(): published = json.loads(path.read_text()) launch = ProcessLaunchResource.from_dict(published["launch"]) receipt = receipts.get(published["containment_id"]) - abandoned = receipt is not None and process_ownership.verify(receipt.get("manager_pid"), receipt.get("manager_token")) in { - process_ownership.GONE, process_ownership.FOREIGN} + abandoned = (receipt is not None + and type(receipt.get("manager_pid")) is int and receipt["manager_pid"] > 0 + and isinstance(receipt.get("manager_token"), str) and bool(receipt["manager_token"]) + and process_ownership.verify(receipt["manager_pid"], receipt["manager_token"]) in { + process_ownership.GONE, process_ownership.FOREIGN}) if (published.get("job") is None and path == launch_path(launch.generation) and (receipt is None or ( receipt.get("launch_generation") == launch.generation and receipt.get("id") == published["containment_id"] - and ((receipt.get("release") or {}).get("dead") is True or abandoned)))): + and abandoned))): # Already under the publication lock; no nested file lock. path.unlink() retired += 1 diff --git a/tests/test_wave3_launch_cost_lifecycle.py b/tests/test_wave3_launch_cost_lifecycle.py index d7cb70b4e..acfb9bc43 100644 --- a/tests/test_wave3_launch_cost_lifecycle.py +++ b/tests/test_wave3_launch_cost_lifecycle.py @@ -128,6 +128,7 @@ def test_old_generation_retirement_cannot_delete_replacement(workspace): @pytest.mark.parametrize('manager,release,retired', [ (process_ownership.OWNED, False, False), (process_ownership.UNVERIFIABLE, False, False), + (process_ownership.OWNED, True, False), (process_ownership.UNVERIFIABLE, True, False), (process_ownership.GONE, False, True), (process_ownership.FOREIGN, False, True), (process_ownership.GONE, True, True), ]) @@ -178,3 +179,14 @@ def test_unreadable_receipts_cannot_retire_live_consumers(workspace, receipt_dat containment._store_path().write_text(receipt_data) assert resources.prune_foreground_publications() == 0 assert resources.launch_path(launch.generation).is_file() + + +def test_missing_manager_identity_cannot_retire_attachment(workspace, monkeypatch): + admitted = authority(workspace) + launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch + resources.publish_launch(launch, admitted, 'receipt') + atomic_write_json(containment._store_path(), {'receipt': {'id': 'receipt', + 'launch_generation': launch.generation, 'release': {'dead': True}}}) + monkeypatch.setattr(process_ownership, 'verify', lambda *args: pytest.fail('Missing manager treated as observed')) + assert resources.prune_foreground_publications() == 0 + assert resources.launch_path(launch.generation).is_file()