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:
GodopuandClaude Opus 5 committed 2026-09-06 18:53:22 +09:00
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.