Skip to content

feat(decisions): run the outbound decision lane, so a halt reaches a phone - #297

Merged
abrichr merged 8 commits into
mainfrom
feat/decision-relay-supervisor
Jul 28, 2026
Merged

feat(decisions): run the outbound decision lane, so a halt reaches a phone#297
abrichr merged 8 commits into
mainfrom
feat/decision-relay-supervisor

Conversation

@abrichr

@abrichr abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member

Stacked on #296. That PR built the context and the transport and said, in its own "not in this PR" list, that nothing spawns the relay loop yet. This is that loop.

Why the lane was still unreachable after #296

DecisionRelay is a transport. It publishes one pause the caller already resolved, and executes one decision against a run_dir and AttentionItem the caller already knows. Grepping the workspace, nothing called it. So a dental practice with no reverse proxy still had no way for a halt to arrive on a phone — the lane existed and was dead.

What this adds

console.decision_supervisor.DecisionSupervisor owns a runs root rather than one run:

  • every cycle it publishes every currently open attended pause, so a halt becomes answerable at app.openadapt.ai without anyone calling anything;
  • an answered decision is resolved back to the pause it was minted from before it is executed; and
  • console --attend --allow-actions --remote-decisions runs it as a daemon thread beside the loopback server.

The part worth reviewing closely: resolution is by capability digest, not by position

DecisionRelay.serve_once(run_dir, item, ...) is correct only when exactly one pause is open. A practice with two runs halted at once is the ordinary case, not the exotic one, and the caller would otherwise apply an answer to whichever pause it happened to be holding.

DecisionSupervisor.resolve re-scans the runs root at decision time and requires both the relayed task_id and the relayed capability_digest to equal what the engine's own signed capability file produces right now.

This is deliberately not presented as the safety boundary. execute_remote_attended_action still revalidates the capability, takes the single-flight lease, re-reads the live application, and re-proves every will_recheck contract. What resolution adds is protection against a correct answer applied to the wrong run — which revalidation alone would not catch, because both pauses are genuinely open and both capabilities genuinely validate. test_a_decision_is_executed_against_the_pause_it_was_minted_from and test_one_cycle_publishes_executes_and_acknowledges_without_a_caller both open two runs and answer the second.

A decision matching no open pause is acknowledged stale. One whose deadline has passed — or whose deadline cannot be parsed — is acknowledged expired. An unreadable deadline is not a licence to act. Neither is executed.

Why a thread inside the console is correct, not merely convenient

That process already owns the deployment-bound AttendedActionService a continuation needs, and execute_attended_action takes a single-flight lease over the pause (attended.py:1087). So an answer arriving from the phone and one taken in the local browser cannot both execute. The lane's lifetime is the server's; serve() stops it in a finally.

Fail loud, never degrade

Every start-up refusal is a SystemExit: missing OPENADAPT_RUNNER_TOKEN, human_decisions.remote not enabled, a read-only console, or a plaintext control-plane origin. An operator who passed --remote-decisions and silently got a loopback-only console would believe a phone can answer a halt while nothing is listening for one — the exact "looks like it works" failure this lane must not have.

Re-publishing after unknown — deliberate, and not a blind retry

publish still reports published / already_published / unknown, and an unknown pause is never described as reachable. The supervisor does re-POST it on the next cycle, because POST /api/human-decisions/tasks is an idempotent upsert keyed on the signed task and a divergent projection for the same task is rejected rather than overwriting (see #184's sameSignedProjection). The rule the relay states — never retry an operation whose effect may already have happened and would happen twice — is preserved, because this operation cannot happen twice. The reasoning is written down in the module docstring rather than left implicit.

What this does NOT close

docs/DECISION_DELIVERY.md now carries a "what is not done yet" section, because a half-wired lane that reads as finished is worse than an honest gap:

  • Desktop does not pass the flag yet. engine/portal/service.py spawns openadapt-flow console --attend --allow-actions; the installer bundles a pinned frozen Flow, so the pin has to move to a release containing --remote-decisions first.
  • The hosted side must be deployed (feat: compiled-program visualizer (shared graph spec + CLI render) #184) before the lane carries context rather than identifiers alone.

Tests

27 new in tests/test_decision_supervisor.py, built on test_decision_relay.py's own _bound_relay_payload / _sign helpers so this file cannot agree with itself while disagreeing with the wire. Includes a full cycle that publishes two real halted runs, executes the answer to the second through the unmodified governed path, and acknowledges it.

ruff check / ruff format --check, mypy, scripts/validate_claims.py --check and scripts/check_consistency.py all clean. tests/test_halt_detail.py::test_resolution_halt_says_what_broke_and_what_continue_will_recheck fails identically on unmodified origin/main in this environment and is unrelated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

@abrichr

abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Merge order matters here — please do not merge this before #296, and rebase rather than merging main in.

This is stacked on feat/hosted-decision-context (#296). #296 is set to squash, so once it lands, main gets one commit whose content matches six commits that are still ancestors of this branch. Retargeting alone would leave this PR showing a duplicated diff.

The correct move after #296 merges:

git fetch origin
git rebase --onto origin/main origin/feat/hosted-decision-context feat/decision-relay-supervisor
git push --force-with-lease

(--onto drops #296's commits rather than replaying them onto their own squashed equivalent.)

CI is green on the exact current head 20762a9test, e2e-browser, lint, mypy-strict-safety, wheel, phi-guard, windows-mock and the rest all pass. The rebase will re-run it, which is expected.

Note that another session is also updating #296's branch (it has since taken main merges for #298 and #300). My two fixes there — _ENGINE_DISPOSITION and the digest-collision scan — are both ancestors of its current tip dbabbcb, so nothing was lost.

Base automatically changed from feat/hosted-decision-context to main July 28, 2026 04:16
abrichr and others added 6 commits July 28, 2026 01:04
…phone

`DecisionRelay` is a transport: it publishes ONE pause the caller already
resolved and executes ONE decision against a run_dir and AttentionItem the
caller already knows. Nothing in the engine called it. So the hosted lane was
reachable in principle and dead in practice -- a dental practice with no reverse
proxy still had no way for a halt to arrive on a phone.

This is the missing half.

`console.decision_supervisor.DecisionSupervisor` owns a `runs` root rather than
one run. Every cycle it publishes EVERY currently open attended pause, so a halt
becomes answerable at app.openadapt.ai without anyone calling anything.

Resolution is by capability digest, not by position. `DecisionRelay.serve_once`
takes its run and item as arguments, which is correct only when exactly one
pause is open; a practice with two halted runs is the ordinary case, not the
exotic one. `DecisionSupervisor.resolve` re-scans the runs root at decision time
and requires BOTH the relayed `task_id` and the relayed `capability_digest` to
equal what the engine's own signed capability file produces right now. That is
not the safety boundary -- `execute_remote_attended_action` still revalidates the
capability, takes the single-flight lease, re-reads the live application and
re-proves every `will_recheck` contract. It is what keeps a CORRECT answer from
being applied to the WRONG run, which revalidation alone would not catch when
both pauses are genuinely open.

A decision matching no open pause is acknowledged `stale`. One whose deadline
has passed, or whose deadline cannot be parsed, is acknowledged `expired`. An
unreadable deadline is not a licence to act. Neither is executed.

`console --attend --allow-actions --remote-decisions` starts the lane as a
daemon thread beside the loopback server. That process already owns the
deployment-bound action service a continuation needs, and
`execute_attended_action` takes a single-flight lease over the pause, so an
answer from the phone and one from the local browser cannot both execute.

Every start-up refusal is a hard exit, never a disabled feature: a missing
runner token, remote issuance not enabled, a read-only console, or a plaintext
control-plane origin each stop the console. An operator who passed
`--remote-decisions` and silently got a loopback-only console would believe a
phone can answer a halt while nothing is listening for one.

The vocabulary stays the relay's. A publish reports `published`,
`already_published` or `unknown`, and an `unknown` pause is never described as
reachable. Re-publishing after `unknown` is deliberate and is not a blind retry:
`POST /api/human-decisions/tasks` is an idempotent upsert keyed on the signed
task, and a divergent projection for the same task is rejected rather than
overwriting, so the operation cannot happen twice.

`docs/DECISION_DELIVERY.md` gains the exact command a customer runs and a "what
is not done yet" section: Desktop does not pass the flag yet (the installer
pins a frozen Flow, so the pin has to move first), and the control plane must be
deployed before the lane carries context rather than identifiers alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
`publish_open_pauses` let a `RelayRefused` propagate. A single pause the control
plane rejects would therefore abort the whole pass, back the loop off, and leave
every OTHER halt in the practice unreachable on a phone -- the failure mode is
inverted from the one the refusal is protecting against. It is now recorded per
pause, in a new `PublishReport.refused`, and the loop continues.

Also stop re-POSTing a pause the control plane has already accepted. A pause can
stay open for hours, and the signed task is a deterministic function of the
capability, so publishing it every 25-second cycle sends identical bytes for no
new information.

The memo is keyed on `(task_id, capability_digest)` and REBUILT each pass rather
than updated, so a pause that closed stops being remembered and a later pause
that reuses its identity is republished. It is in-process only, deliberately: a
restarted supervisor republishes, which is idempotent, rather than trusting a
file's claim that a remote surface still holds something.

An `unknown` publish is deliberately not memoized. It is re-POSTed next cycle,
which either resolves the uncertainty or leaves it unchanged, and cannot happen
twice because the endpoint is an idempotent upsert keyed on the signed task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
`already_published` means "the control plane said it already held this". A pause
skipped by the in-process memo was not asked about this pass, so counting it
there claims an observation the supervisor did not make -- the same discipline
the relay applies when it refuses to say "delivered".

It now reports under `previously_confirmed`. `certain_count` still counts it,
because the pause IS answerable; only the provenance of that knowledge is now
distinguishable by a caller rendering operator copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
A governed refusal is acknowledged and re-raised, and the loop treats it as an
answer rather than an outage -- correctly, because the transport is healthy and
a refusal must not slow the lane down for every other pause.

But if the acknowledgement is itself uncertain, the decision stays leased
server-side and is re-delivered at once, and a poll with a decision waiting
returns immediately. The loop would then spin at full speed on a decision it
refuses every time.

It now takes a flat one-second floor after a refusal. Not a backoff: the
consecutive-failure counter is untouched, so a refusal never raises the delay
for anything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
`reject` terminates a run and `escalate` parks it, so a Reject delivered to
the wrong open pause is the worst outcome this lane can produce. The test opens
two halted runs, rejects the second, and asserts the executed decision carries
that pause id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
@abrichr
abrichr force-pushed the feat/decision-relay-supervisor branch from 20762a9 to 1535322 Compare July 28, 2026 05:05
@abrichr
abrichr merged commit 3aa71b6 into main Jul 28, 2026
16 checks passed
@abrichr
abrichr deleted the feat/decision-relay-supervisor branch July 28, 2026 06:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant