11 KiB
Spec: Preserve the Intended Turn Role Across Auth Failures, Fallbacks, and Service Handoffs
Summary
The invalid token / 401 issue is the trigger, but it is not the workflow bug.
The workflow bug is that a logical paired turn can lose its original role (reviewer / arbiter) during fallback or recovery. A failed reviewer turn must remain a reviewer turn until it either:
- completes with a reviewer verdict,
- escalates to arbiter / human by an explicit reviewer outcome, or
- is deliberately cancelled.
It must not become an owner execution merely because Claude auth failed, token rotation failed, a fallback handoff was created, or a retry path was entered.
Confirmed problems in the current code
1) Service handoff polling ignores target_service_id
In src/message-runtime-handoffs.ts, pending handoffs are loaded with getAllPendingServiceHandoffs() and then claimed immediately.
That means any runtime instance can claim any pending handoff, even if the handoff was created for another service such as codex-review.
The database layer already has the correct filtered accessor:
getPendingServiceHandoffs(targetServiceId)insrc/db/service-handoffs.ts
but the runtime is not using it.
This is the clearest reason a reviewer fallback handoff can end up being executed by the wrong service process.
2) The origin run still performs generic paired recovery after delegating to a fallback handoff
In src/message-agent-executor.ts, maybeHandoffToCodex(...) creates a service handoff and returns success.
But the originating paired execution still reaches asyncFinalize() in src/message-agent-executor-paired.ts, where it can:
- call
completePairedExecutionContext(...), - preserve the task in
review_ready, and - queue a generic paired follow-up after a failed reviewer / arbiter execution.
So one logical failure path can produce two continuation mechanisms at once:
- the explicit service handoff, and
- the normal paired follow-up scheduler.
That is not a clean model. The turn should continue through one mechanism only.
3) Role loss is catastrophic because owner preparation mutates reviewer states
In src/paired-execution-context.ts, the owner branch resets review_ready / in_review back to active.
So if a reviewer handoff is ever resolved as owner, the system does not merely use the wrong bot; it also mutates the paired task into the wrong workflow state.
This is why role resolution errors must fail closed, not silently fall back to owner.
4) Some queued paired runs still rely on inferred role instead of an explicit forced role
src/message-runtime-flow.ts and src/message-runtime-queue.ts still allow some queued turns to be executed based on inferred task status rather than always carrying an explicit role invariant.
That is acceptable in the happy path, but it is not robust under retries, stale revisions, or recovery after handoff / error conditions.
Desired invariant
A logical paired turn must carry an explicit immutable role:
- owner turn stays owner,
- reviewer turn stays reviewer,
- arbiter turn stays arbiter.
Backend recovery may change:
- provider (
claude->codex), - service runtime (
claude->codex-review), - session,
- token,
- retry count,
- process,
but it must not change the logical turn role.
Minimal clean fix
A. Only the target service may claim a service handoff
Change src/message-runtime-handoffs.ts to use the filtered pending-handoff accessor for the current runtime service instead of the unfiltered accessor.
Implementation direction:
- replace
getAllPendingServiceHandoffs()withgetPendingServiceHandoffs(SERVICE_SESSION_SCOPE) - do not let the owner runtime claim a reviewer-targeted handoff
- do not let the reviewer runtime claim an owner-targeted handoff
This is the smallest and most important correctness fix.
B. A delegated fallback handoff must suppress normal paired recovery in the origin run
When maybeHandoffToCodex(...) successfully creates a service handoff, the originating paired run should enter a delegated state.
In that delegated state, the origin run should:
- stop heartbeat,
- release its lease if needed,
- skip
completePairedExecutionContext(...), and - skip generic paired follow-up enqueue.
The delegated handoff is now the sole continuation path for that logical turn.
This avoids double continuation (service_handoff + paired_follow_up) for the same reviewer failure.
C. Never silently default a claimed handoff to owner when role resolution fails
In src/message-runtime-handoffs.ts, if resolveHandoffRoleOverride(...) returns undefined, the handoff should be failed explicitly.
It should not continue with an implicit owner path.
Recommended behavior:
failServiceHandoff(handoff.id, 'Cannot resolve intended handoff role')- log the row fields (
target_role,intended_role,reason,target_service_id) - return without executing the turn
Failing closed is safer than silently mutating reviewer state into owner state.
D. Always pass an explicit role for queued paired turns
Harden the queued turn path so the runtime always executes the exact intended role.
Implementation direction:
- in
src/message-runtime-queue.ts, useforcedRole = turnRolefor paired tasks, not only for the mismatch case - in
src/message-runtime-flow.ts, include explicit owner / reviewer / arbiter role on pending turns and pass it intoexecuteTurn(...)
This removes unnecessary dependence on mutable task status during recovery.
Recommended deeper refactor
The real long-term fix is to make the logical turn a first-class database entity.
Right now the system derives turn intent from a combination of:
- task status,
- last persisted turn output role,
- pending reservation rows,
- service handoff rows,
- execution leases.
That is workable in the happy path, but it is too indirect for failure recovery.
Introduce a persistent turn-attempt model
Create a table (or extend the existing paired reservation / lease model) that stores:
turn_idtask_idtask_updated_atroleintent_kindstate(queued,running,delegated,completed,failed,cancelled)executor_service_idexecutor_agent_typeattempt_nocreated_at,updated_at- optional
parent_turn_idorhandoff_from_attempt_id
Then enforce these rules:
- The scheduler creates a turn record once.
- Recovery / fallback never creates a new logical turn role.
- Fallback only updates the executor fields or creates a new attempt of the same turn.
- The next workflow step is created only when the current turn reaches a terminal state.
With this design:
reviewerremains reviewer even if execution moves from Claude to Codex- token rotation is just another attempt of the same reviewer turn
- handoff cannot accidentally become owner because role is stored on the turn itself
Service handoffs should reference the logical turn
A service handoff row should carry enough identity to prove what it belongs to, at minimum:
paired_task_idtask_updated_atroleintent_kind- ideally
turn_id
Without that linkage, a handoff is just “some prompt for this chat”, which is too weak for strong recovery semantics.
File-by-file guidance
src/message-runtime-handoffs.ts
Required:
- claim only
getPendingServiceHandoffs(SERVICE_SESSION_SCOPE) - fail closed if role cannot be resolved
- add structured logging for
target_service_id,target_role,intended_role, and the resolved role
src/message-agent-executor.ts
Required:
- when a fallback handoff is created, mark the current paired run as delegated
- do not let the origin run also go through normal failed-reviewer recovery
src/message-agent-executor-paired.ts
Required:
- add a delegated completion path that releases / stops the current run without mutating the paired task state
- skip executor-side follow-up enqueue for delegated runs
src/message-runtime-queue.ts
Required:
- always pass
forcedRolefor paired turns
src/message-runtime-flow.ts
Required:
- carry explicit role in
PendingPairedTurn - pass that role into
executeTurn(...)
src/paired-execution-context.ts
No semantic change is required here for the minimal patch, but this file explains why misrouting is severe:
- owner preparation resets
review_ready/in_reviewback toactive
That behavior is valid only when the turn is truly owner.
Acceptance criteria
- A reviewer turn that fails with Claude auth / token errors and falls back to Codex must still be executed as a reviewer turn.
- The owner runtime must not claim reviewer-targeted service handoffs.
- Creating a fallback handoff must not also enqueue generic paired reviewer recovery from the same origin run.
- If the fallback handoff later fails, the requeued turn must still be reviewer, not owner.
- No path may silently default an unresolved claimed handoff to owner.
- Reviewer / arbiter recovery must preserve the intended role even when credentials are broken.
Regression tests to add
1) Reviewer handoff is only claimed by the target service
Add a test proving that a target_service_id = codex-review handoff is invisible to a codex-main runtime poller.
2) Reviewer fallback does not also enqueue generic paired recovery from the origin run
Simulate:
- reviewer Claude auth failure
- service handoff creation
Expect:
- no executor-side paired follow-up is enqueued from the origin run
- only the service handoff remains as the continuation path
3) Claimed reviewer handoff executes with reviewer role invariant
Simulate a claimed reviewer handoff and assert that:
forcedRole === reviewer- the resolved logical turn role remains reviewer
- owner preparation is not entered
4) Unresolvable handoff role fails closed
Simulate a malformed handoff row with:
- no
target_role - no
intended_role - no reason prefix
Expect the handoff to fail, not to run as owner.
5) Failed reviewer fallback requeues reviewer again, not owner
Simulate:
- reviewer handoff execution fails
- task remains
review_ready
Expect the next queued logical turn to remain reviewer.
Bottom line
Yes — this is a workflow bug.
A token outage should cause:
- retry,
- rotation,
- handoff,
- or same-role requeue,
but it should never change the logical turn from reviewer to owner unless a real reviewer result explicitly changed the task state.
The smallest clean patch is:
- filter handoff claiming by
target_service_id, - treat fallback handoff as delegated continuation (not as a normal failed turn),
- fail closed when handoff role cannot be resolved, and
- always pass explicit
forcedRolefor queued paired turns.
The real architectural fix is to store the logical turn explicitly and treat fallback / retry as attempts of that same turn instead of inferring intent from task status during recovery.