4343 get_session ,
4444)
4545from oddish .core .harbor_artifacts import cache_write_tokens_from_trajectory
46+ from oddish .core .cost_basis import CANCELLED_HARBOR_STAGE
4647from oddish .core .llm_key_fingerprint import platform_key_hash_for_provider
4748from oddish .db .models import WorkerJobKind , WorkerJobModel , WorkerJobStatus
4849from oddish .db .storage import resolve_task_directory
@@ -314,7 +315,8 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
314315 """Execute a probe trial in-process and mirror status to the DB.
315316
316317 Status transitions: ``QUEUED`` -> ``RUNNING`` -> ``SUCCESS``
317- (or ``FAILED`` on exception, with ``error_message`` populated).
318+ (or ``FAILED`` on exception, with ``error_message`` populated) on both the
319+ trial and its worker job.
318320
319321 When ``dry_run`` is True, skips the actual Harbor call. Used in
320322 tests to exercise the status-transition path without spinning up
@@ -326,8 +328,9 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
326328 # and a gated (BLOCKED) LLM trial is skipped until the baseline gate
327329 # releases it. ``run_trial_locally`` is the only dispatch entrypoint, so
328330 # this claim is the single choke point that prevents double-dispatch.
331+ claimed_at = datetime .now (timezone .utc )
329332 async with get_session () as session :
330- claimed = (
333+ claimed_trial_id = (
331334 await session .execute (
332335 update (TrialModel )
333336 .where (
@@ -344,19 +347,47 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
344347 )
345348 .values (
346349 status = TrialStatus .RUNNING ,
347- started_at = datetime . now ( timezone . utc ) ,
350+ started_at = claimed_at ,
348351 )
349- .returning (TrialModel .org_id , TrialModel . billed_user_id )
352+ .returning (TrialModel .id )
350353 )
351- ).one_or_none ()
352- if claimed is None :
354+ ).scalar_one_or_none ()
355+ if claimed_trial_id is not None :
356+ org_id , billed_user_id = (
357+ await session .execute (
358+ select (TrialModel .org_id , TrialModel .billed_user_id ).where (
359+ TrialModel .id == trial_id
360+ )
361+ )
362+ ).one ()
363+ # Local mode bypasses the unified dispatcher, so it must mirror the
364+ # scheduling row itself. The baseline gate treats worker_jobs as
365+ # authoritative and must never mistake a completed local baseline's
366+ # original QUEUED row for work that can still retry.
367+ await session .execute (
368+ update (WorkerJobModel )
369+ .where (
370+ WorkerJobModel .kind == WorkerJobKind .TRIAL ,
371+ WorkerJobModel .subject_table == "trials" ,
372+ WorkerJobModel .subject_id == trial_id ,
373+ WorkerJobModel .status .in_ (
374+ (WorkerJobStatus .QUEUED , WorkerJobStatus .RETRYING )
375+ ),
376+ )
377+ .values (
378+ status = WorkerJobStatus .RUNNING ,
379+ claimed_at = claimed_at ,
380+ started_at = claimed_at ,
381+ heartbeat_at = claimed_at ,
382+ )
383+ )
384+ if claimed_trial_id is None :
353385 logger .info (
354386 "local_runner: trial %s not claimable (already dispatched, gated, "
355387 "or gone), skipping" ,
356388 trial_id ,
357389 )
358390 return
359- org_id , billed_user_id = claimed
360391 logger .info ("local_runner: trial %s -> RUNNING" , trial_id )
361392
362393 failure : Exception | None = None
@@ -368,6 +399,7 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
368399 failure = exc
369400
370401 completed = False
402+ finished_at = datetime .now (timezone .utc )
371403 async with get_session () as session :
372404 trial = await session .get (TrialModel , trial_id , with_for_update = True )
373405 if trial is None :
@@ -382,7 +414,7 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
382414 else :
383415 trial .status = TrialStatus .SUCCESS
384416 logger .info ("local_runner: trial %s -> SUCCESS" , trial_id )
385- trial .finished_at = datetime . now ( timezone . utc )
417+ trial .finished_at = finished_at
386418 completed = True
387419 else :
388420 logger .info (
@@ -391,6 +423,47 @@ async def run_trial_locally(trial_id: str, *, dry_run: bool = False) -> None:
391423 trial .status .value ,
392424 )
393425
426+ # A cancellation or another terminal writer can win while Harbor is
427+ # still exiting. Preserve that trial outcome, but do not leave local
428+ # mode's scheduling row RUNNING forever: the baseline gate treats an
429+ # active worker job as authoritative retry evidence.
430+ if trial is not None and trial .status in (
431+ TrialStatus .SUCCESS ,
432+ TrialStatus .FAILED ,
433+ TrialStatus .SKIPPED ,
434+ ):
435+ cancelled = (
436+ trial .status == TrialStatus .SKIPPED
437+ or trial .harbor_stage == CANCELLED_HARBOR_STAGE
438+ )
439+ if cancelled :
440+ worker_job_status = WorkerJobStatus .CANCELLED
441+ elif trial .status == TrialStatus .SUCCESS :
442+ worker_job_status = WorkerJobStatus .SUCCESS
443+ else :
444+ worker_job_status = WorkerJobStatus .FAILED
445+ settled_at = trial .finished_at or finished_at
446+ await session .execute (
447+ update (WorkerJobModel )
448+ .where (
449+ WorkerJobModel .kind == WorkerJobKind .TRIAL ,
450+ WorkerJobModel .subject_table == "trials" ,
451+ WorkerJobModel .subject_id == trial_id ,
452+ WorkerJobModel .status == WorkerJobStatus .RUNNING ,
453+ )
454+ .values (
455+ status = worker_job_status ,
456+ finished_at = settled_at ,
457+ heartbeat_at = settled_at ,
458+ next_retry_at = None ,
459+ error_message = (
460+ None
461+ if worker_job_status == WorkerJobStatus .SUCCESS
462+ else trial .error_message
463+ ),
464+ )
465+ )
466+
394467 from oddish .core .quota_enforcement import enforce_trial_quotas_until_checked
395468
396469 # Enforce settled spend, but run hooks only for the winning completion.
0 commit comments