|
13 | 13 |
|
14 | 14 | from __future__ import annotations |
15 | 15 |
|
| 16 | +import httpx |
| 17 | + |
16 | 18 | from jamjet.cli.main import _worker_loop # noqa: E402 |
17 | 19 |
|
18 | 20 | # ── Test handler functions ──────────────────────────────────────────────────── |
@@ -97,6 +99,30 @@ async def heartbeat_work_item(self, item_id: str, worker_id: str, lease_fence: i |
97 | 99 | self.heartbeat_calls.append({"item_id": item_id, "lease_fence": lease_fence}) |
98 | 100 |
|
99 | 101 |
|
| 102 | +def _http_status_error(status: int) -> httpx.HTTPStatusError: |
| 103 | + """Build the exact exception JamjetClient.complete_work_item raises on a non-2xx |
| 104 | + response: httpx's HTTPStatusError carrying `.response.status_code` (the engine |
| 105 | + returns 409 when the echoed lease fence no longer matches).""" |
| 106 | + req = httpx.Request("POST", "http://runtime/work-items/wi/complete") |
| 107 | + resp = httpx.Response(status, request=req) |
| 108 | + return httpx.HTTPStatusError(f"HTTP {status}", request=req, response=resp) |
| 109 | + |
| 110 | + |
| 111 | +class _Conflict409Client(_StubClient): |
| 112 | + """Stub whose complete_work_item raises a 409 — i.e. the lease was reclaimed and |
| 113 | + a NEW worker now owns the item; this (stale) worker's settle is rejected.""" |
| 114 | + |
| 115 | + async def complete_work_item(self, *args: object, **kwargs: object) -> None: |
| 116 | + raise _http_status_error(409) |
| 117 | + |
| 118 | + |
| 119 | +class _ServerError500Client(_StubClient): |
| 120 | + """Stub whose complete_work_item raises a NON-409 error (transient server fault).""" |
| 121 | + |
| 122 | + async def complete_work_item(self, *args: object, **kwargs: object) -> None: |
| 123 | + raise _http_status_error(500) |
| 124 | + |
| 125 | + |
100 | 126 | # ── Fixtures / constants ────────────────────────────────────────────────────── |
101 | 127 |
|
102 | 128 | _ADD_ITEM: dict = { |
@@ -275,17 +301,46 @@ async def test_worker_echoes_lease_fence_on_complete() -> None: |
275 | 301 |
|
276 | 302 |
|
277 | 303 | async def test_worker_absent_fence_forwarded_as_zero() -> None: |
278 | | - """An item with no real fence (lease_fence=0) forwards 0; the client omits it, |
279 | | - keeping the unfenced fallback backward-compatible.""" |
280 | | - stub = _StubClient(claimed_item=_ADD_ITEM) |
| 304 | + """When the claim response OMITS the lease_fence key ENTIRELY (not even an |
| 305 | + explicit 0), the worker must still forward 0 — the client then drops it, keeping |
| 306 | + the unfenced fallback backward-compatible. |
| 307 | +
|
| 308 | + Regression: this previously claimed an item built from _ADD_ITEM, which already |
| 309 | + carries an explicit lease_fence=0, so it only exercised the explicit-0 path and |
| 310 | + never the missing-key path it is named for. Build an item without the key.""" |
| 311 | + no_fence_item = {k: v for k, v in _ADD_ITEM.items() if k != "lease_fence"} |
| 312 | + assert "lease_fence" not in no_fence_item, "the key must be truly absent from the claim" |
| 313 | + stub = _StubClient(claimed_item=no_fence_item) |
281 | 314 | await _worker_loop(stub, "test-worker", ["python_tool"], once=True) |
282 | 315 |
|
283 | 316 | assert len(stub.complete_calls) == 1 |
284 | 317 | assert stub.complete_calls[0]["lease_fence"] == 0, ( |
285 | | - "absent fence is forwarded as 0 and dropped by the client (unfenced path)" |
| 318 | + "an absent fence is forwarded as 0 and dropped by the client (unfenced path)" |
286 | 319 | ) |
287 | 320 |
|
288 | 321 |
|
| 322 | +async def test_worker_409_complete_is_lost_lease_noop() -> None: |
| 323 | + """A 409 from complete_work_item means our lease fence no longer matches: the |
| 324 | + item was reclaimed and a NEW worker owns it now. The stale worker must treat the |
| 325 | + rejection as a lost-lease no-op — it must NOT call fail_work_item, because that |
| 326 | + would clobber the reclaimed work the new claimant is running. With --once it |
| 327 | + returns cleanly.""" |
| 328 | + stub = _Conflict409Client(claimed_item=_FENCED_ITEM) |
| 329 | + await _worker_loop(stub, "test-worker", ["python_tool"], once=True) |
| 330 | + |
| 331 | + assert len(stub.fail_calls) == 0, "a 409 lost-lease must NOT call fail_work_item" |
| 332 | + |
| 333 | + |
| 334 | +async def test_worker_non_409_complete_error_still_fails() -> None: |
| 335 | + """A NON-409 completion error keeps the existing behavior: it falls through to |
| 336 | + fail_work_item (the lease is still ours; the settle just did not land).""" |
| 337 | + stub = _ServerError500Client(claimed_item=_FENCED_ITEM) |
| 338 | + await _worker_loop(stub, "test-worker", ["python_tool"], once=True) |
| 339 | + |
| 340 | + assert len(stub.fail_calls) == 1, "a non-409 completion error must still call fail_work_item" |
| 341 | + assert stub.fail_calls[0]["item_id"] == "wi-006" |
| 342 | + |
| 343 | + |
289 | 344 | async def test_worker_dispatch_tool_calls_return_reaches_state() -> None: |
290 | 345 | """2j-4 G2: the agent loop's dispatch_tool_calls runs on the worker and its |
291 | 346 | {"messages": [...]} return is posted as the state_patch — so the accumulated |
|
0 commit comments