feat(issue-5): single CAS transition boundary for every job-status write
engine.transition is now the only writer of job status. It implements the target table in state-transitions.md §3: claim, non-terminal metadata, explicit terminals, idempotent re-receipt, refusal of conflicting or late signals with an audit entry, stale-attempt rejection, and deadline timeout. Two write paths become one. update_job_status merged fields unconditionally; pick_pending hand-rolled a correct pending->running swap plus its own audit mirror. Neither was wrong alone, but a rule added to one silently missed the other, which is the shape that produced the phase 1 transport bugs. Both keep their signatures and their preserved legacy bodies behind MAM_ENGINE_CAS=0. A merged `status` value is routed to the request that owns it, so a raw field write can no longer slip past the table. Getting that wrong first was caught by the characterization tests: a metadata merge was overwriting the caller's status with the pre-merge value. Lock discipline is now enforced, not described. The lock covers a decision and one record write; audit mirroring is queued and flushed after release, carrying a monotonic transition_seq so ordering survives the move. A test watches for subprocess, socket and sleep calls while the lock is held and asserts none. Structural correction to my own plan, caught by a phase 1 guard: the plan put the engine in lib_py/loop, but delegation would then have had to import a sibling, which is exactly what mixes trees under a frozen loop. The engine belongs with the registry writers, so it lives in lib_py/delegation and loop consumes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
1 parent
9389d7ebe2
commit
2532e78453
8 files changed
+794
-3
No files matched your search
@@ -393,7 +393,38 @@ def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any])
|
||||
|
||||
|
||||
def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
"""Atomically merge ``fields`` into a job record under the registry lock.
|
||||
"""Merge ``fields`` into a job record through the single transition boundary.
|
||||
|
||||
Signature, return value and exceptions are unchanged: phase 1's G-1..G-4
|
||||
assert through this function, and the shell helpers capture its effects.
|
||||
What changed is who performs the write -- ``lib_py.loop.engine`` is now the
|
||||
only writer of job status, so the CAS rules and the audit mirror exist once
|
||||
instead of twice.
|
||||
|
||||
``MAM_ENGINE_CAS=0`` restores the previous unconditional merge below.
|
||||
"""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
if not engine.cas_enabled():
|
||||
return _update_job_status_legacy(job_id, registry_dir, **fields)
|
||||
|
||||
request = "merge"
|
||||
if "status" in fields:
|
||||
# A merged status value is really a transition request; routing it to the
|
||||
# owning request is what subjects it to the CAS table instead of letting
|
||||
# a raw field write slip past it.
|
||||
request = engine.STATUS_TO_REQUEST.get(fields["status"], "merge")
|
||||
if request != "merge":
|
||||
fields = {k: v for k, v in fields.items() if k != "status"}
|
||||
result = engine.transition(job_id, request, registry_dir=registry_dir,
|
||||
fields=fields, source="update_job_status")
|
||||
return result.record
|
||||
|
||||
|
||||
def _update_job_status_legacy(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
"""Pre-phase-2a body, preserved for MAM_ENGINE_CAS=0 rollback.
|
||||
|
||||
Atomically merge ``fields`` into a job record under the registry lock.
|
||||
|
||||
Always refreshes ``updated_at``. Returns the new record. Raises
|
||||
FileNotFoundError if the job does not exist.
|
||||
|
||||
Reference in new issue
Block a user