@@ -53,7 +53,7 @@ async def _count_memory_units(memory, bank_id: str) -> int:
5353 )
5454
5555
56- async def _run_retain_through_worker (memory , extraction_error : Exception ):
56+ async def _run_retain_through_worker (memory , extraction_error : Exception , * , retry_count : int = 0 ):
5757 """Enqueue a one-item retain and run it through the real poller + executor.
5858
5959 ``extraction_error`` is raised by the mock LLM on the ``retain_extract_facts``
@@ -90,16 +90,18 @@ async def call_raising_on_extract(self, *args, **kwargs):
9090 "operation_id" : str (operation_id ),
9191 "bank_id" : bank_id ,
9292 "contents" : [{"content" : "Alice moved to Berlin in March 2024 and joined Acme as a staff engineer." }],
93+ "_retry_count" : retry_count ,
9394 }
9495 await pool .execute (
9596 """
9697 INSERT INTO async_operations
97- (operation_id, bank_id, operation_type, status, task_payload, worker_id, claimed_at)
98- VALUES ($1, $2, 'retain', 'processing', $3::jsonb, 'test-worker-1', now())
98+ (operation_id, bank_id, operation_type, status, task_payload, retry_count, worker_id, claimed_at)
99+ VALUES ($1, $2, 'retain', 'processing', $3::jsonb, $4, 'test-worker-1', now())
99100 """ ,
100101 operation_id ,
101102 bank_id ,
102103 json .dumps (task_payload ),
104+ retry_count ,
103105 )
104106
105107 poller = WorkerPoller (
@@ -150,3 +152,26 @@ async def test_extraction_failure_is_retried_never_silently_completed(memory, ex
150152 )
151153 assert final ["status" ] == "pending" , f"expected task reset to 'pending' for retry, got { final ['status' ]!r} "
152154 assert final ["retry_count" ] == 1 , f"expected retry_count bumped to 1, got { final ['retry_count' ]} "
155+
156+
157+ @pytest .mark .asyncio
158+ async def test_extraction_failure_at_retry_cap_fails_terminally (memory ):
159+ """Once the worker retry cap is reached, extraction failures must fail terminally.
160+
161+ This guards the recovered-worker path seen in vectorize-io/hindsight#2413:
162+ repeated structured-output parse failures should eventually release the
163+ worker slot and leave an inspectable failed operation, not remain pending or
164+ processing indefinitely.
165+ """
166+ final , bank_id = await _run_retain_through_worker (
167+ memory ,
168+ RuntimeError ("structured JSON parse failed after all retain_extract_facts attempts" ),
169+ retry_count = 3 ,
170+ )
171+ unit_count = await _count_memory_units (memory , bank_id )
172+
173+ assert final ["status" ] == "failed" , f"expected retry-capped task to fail, got { final ['status' ]!r} "
174+ assert final ["retry_count" ] == 3
175+ assert final ["error_message" ] is not None
176+ assert "structured JSON parse failed" in final ["error_message" ]
177+ assert unit_count == 0
0 commit comments