feat(decisions): run the outbound decision lane, so a halt reaches a phone - #297
Conversation
b688238 to
8c0788e
Compare
3d73fe0 to
20762a9
Compare
|
Merge order matters here — please do not merge this before #296, and rebase rather than merging main in. This is stacked on The correct move after #296 merges: ( CI is green on the exact current head Note that another session is also updating #296's branch (it has since taken |
…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
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
20762a9 to
1535322
Compare
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
DecisionRelayis a transport. It publishes one pause the caller already resolved, and executes one decision against arun_dirandAttentionItemthe 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.DecisionSupervisorowns arunsroot rather than one run:app.openadapt.aiwithout anyone calling anything;console --attend --allow-actions --remote-decisionsruns 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.resolvere-scans the runs root at decision time and requires both the relayedtask_idand the relayedcapability_digestto equal what the engine's own signed capability file produces right now.This is deliberately not presented as the safety boundary.
execute_remote_attended_actionstill revalidates the capability, takes the single-flight lease, re-reads the live application, and re-proves everywill_recheckcontract. 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_fromandtest_one_cycle_publishes_executes_and_acknowledges_without_a_callerboth 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 acknowledgedexpired. 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
AttendedActionServicea continuation needs, andexecute_attended_actiontakes 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 afinally.Fail loud, never degrade
Every start-up refusal is a
SystemExit: missingOPENADAPT_RUNNER_TOKEN,human_decisions.remotenot enabled, a read-only console, or a plaintext control-plane origin. An operator who passed--remote-decisionsand 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 retrypublishstill reportspublished/already_published/unknown, and anunknownpause is never described as reachable. The supervisor does re-POST it on the next cycle, becausePOST /api/human-decisions/tasksis an idempotent upsert keyed on the signed task and a divergent projection for the same task is rejected rather than overwriting (see #184'ssameSignedProjection). 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.mdnow carries a "what is not done yet" section, because a half-wired lane that reads as finished is worse than an honest gap:engine/portal/service.pyspawnsopenadapt-flow console --attend --allow-actions; the installer bundles a pinned frozen Flow, so the pin has to move to a release containing--remote-decisionsfirst.Tests
27 new in
tests/test_decision_supervisor.py, built ontest_decision_relay.py's own_bound_relay_payload/_signhelpers 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 --checkandscripts/check_consistency.pyall clean.tests/test_halt_detail.py::test_resolution_halt_says_what_broke_and_what_continue_will_recheckfails identically on unmodifiedorigin/mainin this environment and is unrelated.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM