1313
1414import pytest
1515
16- from test_local_delegation import HOST , brief , service # noqa: F401
16+ from test_local_delegation import HOST , brief , demo , service # noqa: F401
1717from loopx .control_plane .collaboration .inbox import _read
1818from loopx .control_plane .coordination .local_authority import read_canonical_todos_if_promoted
1919from tests .control_plane .host_process_fixture import COUNTER_PROCESS_SOURCE
@@ -42,6 +42,8 @@ def prepare_lease(root, runner, monkeypatch, *, ttl=20):
4242 binding = runner .binding ("analysis" )
4343 runner ._acquire_delegation_lease (runner .path ("lease-lifetime" ), row , binding )
4444 lease = row ["task_lease" ]["lease" ]
45+ if ttl is None :
46+ return lease
4547 renewed = runner ._cli (binding , "task-lease" , "renew" , "--goal-id" , runner .goal_id ,
4648 "--todo-id" , binding ["todo_id" ], "--owner" , binding ["agent_id" ],
4749 "--idempotency-key" , lease ["idempotency_key" ], "--expected-version" , str (lease ["version" ]),
@@ -54,6 +56,164 @@ def inspect(runner):
5456 "--todo-id" , "todo_analyst-initial" )
5557
5658
59+ @pytest .fixture (params = ["file" , "sqlite" ])
60+ def completion_service (tmp_path , request , monkeypatch ):
61+ """Pin a real slow final acceptance command before preparing authority."""
62+ validator = tmp_path / "completion-validator.py"
63+ validator .write_text ('''import sys, time, runpy
64+ from datetime import datetime
65+ from pathlib import Path
66+ root = Path(sys.argv[1])
67+ counter = root / 'validator-calls'
68+ calls = int(counter.read_text()) + 1 if counter.exists() else 1
69+ counter.write_text(str(calls))
70+ if calls == 2:
71+ deadline = datetime.fromisoformat((root / 'completion-prior-expiry').read_text())
72+ time.sleep(max(0, deadline.timestamp() - time.time()) + 0.2)
73+ (root / 'completion-validation-ended').touch()
74+ actual = root / 'project/validation/acceptance.py'
75+ sys.path.insert(0, str(actual.parent))
76+ sys.argv[0] = str(actual)
77+ runpy.run_path(str(actual), run_name='__main__')
78+ ''' )
79+ write = demo .write
80+
81+ def configure (path , value ):
82+ if path .name == "bootstrap.json" :
83+ for criterion in value ["document" ]["criteria" ]:
84+ criterion ["validation_timeout_seconds" ] = 1
85+ if criterion ["id" ] == "analyst-initial" :
86+ # 21 + four one-second criteria stays within the public
87+ # 25-second completion budget, including a 20s deadline.
88+ criterion ["validation_timeout_seconds" ] = 21
89+ criterion ["validation_argv" ][1 ] = str (validator )
90+ return write (path , value )
91+
92+ monkeypatch .setattr (demo , "write" , configure )
93+ return service .__wrapped__ (tmp_path , request , monkeypatch )
94+
95+
96+ @pytest .mark .parametrize ("lost_reply" , [None , "renewal" , "completion" ])
97+ def test_completion_renews_before_validation_and_replays_each_intent (completion_service , monkeypatch , lost_reply ):
98+ root , runner = completion_service
99+ # This case shortens the lease at the completion boundary below. An
100+ # unrelated short Host deadline can cancel execution before that boundary
101+ # under load; the running-Host cases separately exercise that deadline.
102+ original = prepare_lease (root , runner , monkeypatch , ttl = None )
103+ complete , cli = runner ._complete_delegated_todo , runner ._cli
104+ shortened = None
105+ renewal_calls , completion_calls = [], []
106+ dropped = False
107+
108+ def enter_completion (row , binding ):
109+ nonlocal shortened
110+ if shortened is None :
111+ current = inspect (runner )["lease" ]
112+ # Fix the phase boundary, independent of whether the Host happened
113+ # to cross its earlier renewal timer. Use the real canonical API.
114+ shortened = cli (binding , "task-lease" , "renew" , "--goal-id" , runner .goal_id ,
115+ "--todo-id" , binding ["todo_id" ], "--owner" , binding ["agent_id" ],
116+ "--idempotency-key" , current ["idempotency_key" ],
117+ "--expected-version" , str (current ["version" ]), "--ttl-seconds" , "20" )["lease" ]
118+ # Cross the actual pre-renewal deadline, not an assumed amount of
119+ # CLI startup time. Allow cold claim/renew commands to reach the
120+ # boundary; the independent validator still outlives that lease.
121+ (root / "completion-prior-expiry" ).write_text (shortened ["expires_at" ])
122+ return complete (row , binding )
123+
124+ def observe_reply (binding , * args , ** kwargs ):
125+ nonlocal dropped
126+ result = cli (binding , * args , ** kwargs )
127+ phase = None
128+ if args [:2 ] == ("task-lease" , "renew" ):
129+ renewal_calls .append ((args , result ))
130+ phase = "renewal"
131+ elif args [:2 ] == ("todo" , "complete" ):
132+ completion_calls .append ((args , result ))
133+ phase = "completion"
134+ if phase == lost_reply and phase is not None and not dropped :
135+ dropped = True
136+ raise ValueError ("fixture dropped the committed " + phase + " reply" )
137+ return result
138+
139+ monkeypatch .setattr (runner , "_complete_delegated_todo" , enter_completion )
140+ monkeypatch .setattr (runner , "_cli" , observe_reply )
141+ runner .execute ("lease-lifetime" )
142+ if lost_reply :
143+ uncertain = _read (runner .path ("lease-lifetime" ))
144+ assert uncertain ["status" ] == "turn_returned" , uncertain
145+ assert "fixture dropped" in uncertain ["error" ]
146+ assert uncertain ["completion_lease_renewal_version" ] == shortened ["version" ]
147+ assert ("completion_lease_version" in uncertain ) == (lost_reply == "completion" )
148+ runner .execute ("lease-lifetime" )
149+ row = _read (runner .path ("lease-lifetime" ))
150+ assert row ["status" ] == "accepted" , row
151+ assert row ["turn_result" ]["result_kind" ] == "validated_progress"
152+ assert (root / "completion-validation-ended" ).exists ()
153+ assert time .time () > datetime .fromisoformat (shortened ["expires_at" ].replace ("Z" , "+00:00" )).timestamp ()
154+ assert (root / "analyst/initial/host-invocations" ).read_text () == "1"
155+ final = inspect (runner )["lease" ]
156+ assert final ["status" ] == "released"
157+ assert final ["lease_epoch" ] == original ["lease_epoch" ]
158+ assert final ["idempotency_key" ] == original ["idempotency_key" ]
159+ assert final ["version" ] == shortened ["version" ] + 1
160+ assert len (renewal_calls ) == (2 if lost_reply == "renewal" else 1 )
161+ assert len (completion_calls ) == (2 if lost_reply == "completion" else 1 )
162+ for calls in (renewal_calls , completion_calls ):
163+ assert all (args == calls [0 ][0 ] for args , _ in calls )
164+ assert all (result ["provider_revision" ] == calls [0 ][1 ]["provider_revision" ] for _ , result in calls )
165+
166+
167+ @pytest .mark .parametrize ("authority_loss" , ["expiry" , "replacement" ])
168+ def test_completion_renewal_receipt_cannot_revive_lost_execution (service , monkeypatch , authority_loss ):
169+ root , runner = service
170+ prepare_lease (root , runner , monkeypatch )
171+ cli = runner ._cli
172+ dropped = False
173+ completions = []
174+
175+ def lose_renewal_reply (binding , * args , ** kwargs ):
176+ nonlocal dropped
177+ if args [:2 ] == ("todo" , "complete" ):
178+ completions .append (args )
179+ result = cli (binding , * args , ** kwargs )
180+ if args [:2 ] == ("task-lease" , "renew" ) and not dropped :
181+ dropped = True
182+ raise ValueError ("fixture lost renewal response before terminal intent" )
183+ return result
184+
185+ monkeypatch .setattr (runner , "_cli" , lose_renewal_reply )
186+ runner .execute ("lease-lifetime" )
187+ row = _read (runner .path ("lease-lifetime" ))
188+ assert row ["status" ] == "turn_returned" , row
189+ assert "completion_lease_renewal_version" in row
190+ assert "completion_lease_version" not in row
191+ current = inspect (runner )["lease" ]
192+ binding = runner .binding ("analysis" )
193+ args = ("--goal-id" , runner .goal_id , "--todo-id" , binding ["todo_id" ],
194+ "--owner" , binding ["agent_id" ], "--idempotency-key" , current ["idempotency_key" ],
195+ "--expected-version" , str (current ["version" ]))
196+ if authority_loss == "expiry" :
197+ expired = cli (binding , "task-lease" , "renew" , * args , "--ttl-seconds" , "1" )["lease" ]
198+ time .sleep (max (0 , datetime .fromisoformat (expired ["expires_at" ].replace ("Z" , "+00:00" )).timestamp ()
199+ - time .time ()) + 0.1 )
200+ else :
201+ assert cli (binding , "task-lease" , "release" , * args )["ok" ] is True
202+ replacement = cli (binding , "task-lease" , "acquire" , "--goal-id" , runner .goal_id ,
203+ "--todo-id" , binding ["todo_id" ], "--owner" , binding ["agent_id" ],
204+ "--idempotency-key" , "replacement" , "--expected-version" , str (current ["version" ]))
205+ assert replacement ["lease" ]["lease_epoch" ] > current ["lease_epoch" ]
206+ runner .execute ("lease-lifetime" )
207+ rejected = _read (runner .path ("lease-lifetime" ))
208+ assert rejected ["status" ] != "accepted" , rejected
209+ assert "completion_lease_version" not in rejected
210+ assert not completions
211+ assert (root / "analyst/initial/host-invocations" ).read_text () == "1"
212+ snapshot = read_canonical_todos_if_promoted (runtime_root = runner .root , goal_id = runner .goal_id )
213+ todo = next (item for item in snapshot ["todos" ] if item ["todo_id" ] == binding ["todo_id" ])
214+ assert todo ["done" ] is False
215+
216+
57217def await_started (root , future , runner ):
58218 deadline = time .monotonic () + 45
59219 while time .monotonic () < deadline and not (root / "host-started" ).exists ():
@@ -125,11 +285,24 @@ def test_real_revocation_or_new_execution_stops_nested_host_without_acceptance(s
125285 with ThreadPoolExecutor (max_workers = 1 ) as pool :
126286 future = pool .submit (runner .execute , "lease-lifetime" )
127287 await_started (root , future , runner )
128- current = inspect (runner )["lease" ]
129288 binding = runner .binding ("analysis" )
130- runner ._cli (binding , "task-lease" , "release" , "--goal-id" , runner .goal_id ,
131- "--todo-id" , "todo_analyst-initial" , "--owner" , "analyst" ,
132- "--idempotency-key" , original ["idempotency_key" ], "--expected-version" , str (current ["version" ]))
289+ # The live supervisor may renew between inspection and revocation.
290+ # Retry only that CAS race, never a replacement execution or rejection.
291+ for _ in range (5 ):
292+ current = inspect (runner )["lease" ]
293+ assert current ["lease_epoch" ] == original ["lease_epoch" ]
294+ assert current ["idempotency_key" ] == original ["idempotency_key" ]
295+ try :
296+ runner ._cli (binding , "task-lease" , "release" , "--goal-id" , runner .goal_id ,
297+ "--todo-id" , "todo_analyst-initial" , "--owner" , "analyst" ,
298+ "--idempotency-key" , original ["idempotency_key" ],
299+ "--expected-version" , str (current ["version" ]))
300+ break
301+ except ValueError as exc :
302+ if str (exc ) != "canonical task lease release rejected: version_mismatch" :
303+ raise
304+ else :
305+ pytest .fail ("could not revoke the original execution during concurrent renewal" )
133306 if reclaim :
134307 acquired = runner ._cli (binding , "task-lease" , "acquire" , "--goal-id" , runner .goal_id ,
135308 "--todo-id" , "todo_analyst-initial" , "--owner" , "analyst" , "--idempotency-key" , "new-execution" ,
0 commit comments