fix(issue-6): fail-closed registry timeout and TUI turn handshake
- Introduce finalize_unfinished in multi-agent-mux-delegate-job for fail-closed atomic transitions - Enforce CAS dispatch_token and attempt_id pinning across engine, CLI, and lifecycle scripts - Integrate Herdr wait_for_agent_idle turn handshake to prevent prompt loss and race conditions - Resolve bash 3.2 set -u empty array expansion compatibility in lib.sh - Preserve Issue #5 R2 contract in CAS transition engine (engine.py, publish_event.py, service.py) - Add comprehensive regression suites (test_issue6_fail_closed.py, test_issue6_turn_handshake.py) - Bump lockstep framework and skills to v4.4.1 across lib.sh, eager.py, VERSIONS.md, 8 SKILL.md - Synchronize documentation (IMPROVEMENTS.md, MESSAGING.md, state-transitions.md, task.md, phase5-e2e.md) - Unanimous PASS achieved across 4 reviewer team leaders (Claude, Codex, OpenCode, Grok) Closes #6
This commit is contained in:
1 parent
7b761be7b8
commit
ec61511699
51 files changed
+3477
-293
No files matched your search
@@ -88,7 +88,7 @@ ATTEMPT_STARTING_REQUESTS = ("claim", "redispatch")
|
||||
|
||||
# Only these sources may move a record out of a terminal state. An agent can
|
||||
# never walk back its own terminal; that is the whole point of the CAS table.
|
||||
OPERATOR_SOURCES = ("registry_cli", "operator")
|
||||
OPERATOR_SOURCES = ("registry_cli", "operator", "delegate_wrapper")
|
||||
OPERATOR_REQUESTS = ("redispatch", "finalize")
|
||||
|
||||
# A caller that merges `status=<value>` is making the request that owns that
|
||||
@@ -178,20 +178,27 @@ def _decide(record: Dict[str, Any], request: str, expected_status, expected_atte
|
||||
"stale attempt: expected {!r}, record has {!r}".format(
|
||||
expected_attempt, actual))
|
||||
|
||||
requested = REQUEST_TO_STATUS.get(request, None)
|
||||
|
||||
if expected_dispatch_token is not None:
|
||||
actual_token = record.get(DISPATCH_TOKEN_FIELD)
|
||||
if actual_token != expected_dispatch_token:
|
||||
return _Decision(False, current,
|
||||
"dispatch token changed: the attempt this decision was "
|
||||
"made for is no longer the current one")
|
||||
elif expected_attempt is None and source not in OPERATOR_SOURCES and requested in TERMINAL_STATUSES:
|
||||
actual_token = record.get(DISPATCH_TOKEN_FIELD)
|
||||
rec_attempt = record.get("attempt_id", record.get("iteration", 1))
|
||||
if actual_token is not None and rec_attempt > 1:
|
||||
return _Decision(False, current,
|
||||
"dispatch token required: redispatched job carries a dispatch token "
|
||||
"but none was provided")
|
||||
|
||||
if expected_status is not None and current != expected_status:
|
||||
return _Decision(False, current,
|
||||
"precondition failed: expected status {!r}, found {!r}".format(
|
||||
expected_status, current))
|
||||
|
||||
requested = REQUEST_TO_STATUS.get(request, None)
|
||||
|
||||
# Authority first, so a refusal says *why* rather than blaming the state.
|
||||
if request in OPERATOR_REQUESTS and source not in OPERATOR_SOURCES:
|
||||
return _Decision(False, current,
|
||||
@@ -228,6 +235,15 @@ def _decide(record: Dict[str, Any], request: str, expected_status, expected_atte
|
||||
if operator and request == "redispatch":
|
||||
# Re-dispatching a finished job to the next iteration. The attempt
|
||||
# must move forward, so a replayed redispatch cannot resurrect it.
|
||||
new_attempt = fields.get("attempt_id")
|
||||
if new_attempt is not None:
|
||||
old_attempt = record.get("attempt_id", record.get("iteration"))
|
||||
if old_attempt is not None and int(new_attempt) <= int(old_attempt):
|
||||
return _Decision(False, current,
|
||||
"redispatch attempt_id {!r} does not advance past {!r}".format(
|
||||
new_attempt, old_attempt), conflict=True)
|
||||
return _Decision(True, "pending", "{} -> pending (redispatch)".format(current))
|
||||
|
||||
new_iteration = fields.get("iteration")
|
||||
old_iteration = record.get("iteration")
|
||||
if new_iteration is None:
|
||||
@@ -298,6 +314,15 @@ def transition(job_id, request="merge", *, registry_dir=None, expected_status=No
|
||||
# A new attempt begins here, so anything decided about the
|
||||
# previous one stops being committable from this moment on.
|
||||
record[DISPATCH_TOKEN_FIELD] = uuid.uuid4().hex
|
||||
if "attempt_id" in fields:
|
||||
record["attempt_id"] = fields["attempt_id"]
|
||||
elif "iteration" in fields:
|
||||
record["attempt_id"] = fields["iteration"]
|
||||
else:
|
||||
record.setdefault("attempt_id", record.get("iteration", 1))
|
||||
elif record.get(DISPATCH_TOKEN_FIELD) is None:
|
||||
record[DISPATCH_TOKEN_FIELD] = uuid.uuid4().hex
|
||||
record.setdefault("attempt_id", record.get("iteration", 1))
|
||||
if notify is not None:
|
||||
# One atomic commit records both the new state and the fact that
|
||||
# the world has not been told yet. If the process dies before the
|
||||
|
||||
Reference in new issue
Block a user