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:
Godopu committed 2026-09-09 14:02:47 +09:00
1 parent 7b761be7b8
commit ec61511699
51 files changed
+3477 -293

No files matched your search

+28 -3
View File
@@ -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