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
@@ -117,6 +117,37 @@ def pick_pending(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -
|
||||
``running`` atomically under the lock. Returns the job id, or None if no
|
||||
pending job matches. This is how each herdr session takes only its own work
|
||||
without two sessions grabbing the same job."""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
if not engine.cas_enabled():
|
||||
return _pick_pending_legacy(agent_session, registry_dir)
|
||||
|
||||
# Selection still happens under the lock, then the claim itself is committed
|
||||
# by the single transition boundary with an explicit precondition, so two
|
||||
# sessions still cannot both win.
|
||||
with registry_lock(registry_dir):
|
||||
candidates = [
|
||||
record for record in _iter_records(registry_dir)
|
||||
if record.get("status") == "pending" and record.get("agent_session") == agent_session
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda r: r.get("created_at", ""))
|
||||
chosen_id = candidates[0]["job_id"]
|
||||
|
||||
result = engine.transition(chosen_id, "claim", registry_dir=registry_dir,
|
||||
expected_status="pending", source=agent_session)
|
||||
if not result.committed:
|
||||
# Someone else claimed it between selection and commit; the precondition
|
||||
# refused, which is exactly the behaviour the lock used to provide.
|
||||
logger.info("job %s was claimed by another session first", chosen_id)
|
||||
return None
|
||||
logger.info("session %s picked job %s", agent_session, chosen_id)
|
||||
return chosen_id
|
||||
|
||||
|
||||
def _pick_pending_legacy(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Optional[str]:
|
||||
"""Pre-phase-2a body, preserved for MAM_ENGINE_CAS=0 rollback."""
|
||||
with registry_lock(registry_dir):
|
||||
candidates = []
|
||||
for record in _iter_records(registry_dir):
|
||||
@@ -132,8 +163,8 @@ def pick_pending(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -
|
||||
logger.info("session %s picked job %s", agent_session, chosen["job_id"])
|
||||
job_id = chosen["job_id"]
|
||||
updated_at = chosen["updated_at"]
|
||||
# pick_pending writes the record directly (not via update_job_status), so it
|
||||
# mirrors the pending->running transition into the audit log here. Best-effort.
|
||||
# Mirrors the pending->running transition into the audit log outside the
|
||||
# lock. This is the shape the engine now generalises to every transition.
|
||||
mqtt_common.update_logged_status(job_id, "running", updated_at=updated_at)
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
|
||||
Reference in new issue
Block a user