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
60 lines
2.4 KiB
Python
60 lines
2.4 KiB
Python
"""Four-fold execution attribution, as a pure predicate.
|
|
|
|
A terminal state may only be inferred for the attempt that actually ran. Idle is
|
|
not evidence of finishing: a reused session sitting idle from previous work looks
|
|
identical unless job, attempt, session and pane generation all agree AND the
|
|
attempt was observed to actually execute after dispatch was acknowledged.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
REQUIRED_FIELDS = ("job_id", "attempt_id", "agent_session_uuid", "pane_process_generation")
|
|
|
|
|
|
class AttributionResult(object):
|
|
__slots__ = ("ok", "reason")
|
|
|
|
def __init__(self, ok, reason):
|
|
self.ok = ok
|
|
self.reason = reason
|
|
|
|
|
|
def check(dispatch_context, observed=None) -> AttributionResult:
|
|
"""Compare a dispatch record against what was observed.
|
|
|
|
`observed` may carry any subset of the four identifiers; every key it does
|
|
supply must match. A missing identifier is never treated as agreement.
|
|
"""
|
|
if not isinstance(dispatch_context, dict):
|
|
return AttributionResult(False, "dispatch_context missing")
|
|
|
|
for field in REQUIRED_FIELDS:
|
|
if field not in dispatch_context:
|
|
return AttributionResult(False, "missing attribution field: {}".format(field))
|
|
value = dispatch_context[field]
|
|
if value is None or value == "":
|
|
return AttributionResult(False, "empty attribution field: {}".format(field))
|
|
|
|
if not dispatch_context.get("dispatch_ack"):
|
|
return AttributionResult(False, "dispatch was never acknowledged")
|
|
|
|
# The anchor is the whole point: without it, idle is just idle.
|
|
if not dispatch_context.get("execution_anchor_observed"):
|
|
return AttributionResult(False, "no execution anchor observed for this attempt")
|
|
|
|
for field, value in (observed or {}).items():
|
|
if field in REQUIRED_FIELDS and value != dispatch_context[field]:
|
|
return AttributionResult(
|
|
False, "attribution mismatch on {}".format(field))
|
|
|
|
return AttributionResult(True, "four-fold attribution satisfied")
|
|
|
|
|
|
def pane_matches_generation(pane_id, pane_process_generation) -> bool:
|
|
"""`pane_process_generation` is '<pane_id>:<pid>:<start>'.
|
|
|
|
Compared on the whole leading segment so w2X:p1 never matches w2X:p10.
|
|
"""
|
|
if not pane_id or not pane_process_generation:
|
|
return False
|
|
return str(pane_process_generation).startswith(str(pane_id) + ":")
|