Skip to content

P4b1: agent turn contract with cancellable turns - #1301

Merged
DROOdotFOO merged 19 commits into
masterfrom
omp/p4b1-turn-contract
Oct 27, 2026
Merged

DROOdotFOO merged 19 commits into
masterfrom
omp/p4b1-turn-contract

Conversation

@DROOdotFOO

@DROOdotFOO DROOdotFOO commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

What / why

P4b1 of the OMP pivot (ADR-0042): the agent-side turn contract that raxol acp --agent (P4b2) will drive.

  • Comm.call(agent, {:acp_prompt, text, approver}, timeout) asks a session for one turn; the agent answers {:ok, text, usage} (so the call returns {:ok, {:ok, text, usage}}; Comm.call still wraps replies as before).
  • approver is an arity-3 :tool_authorizer (Raxol.Agent.ToolPolicy shape). The reference agent passes it to ToolConverter.dispatch_tool_call/3, so the turn's tool calls must pass it (P4b2 plugs Permission.authorizer/2 in here).
  • Raxol.Agent.Session stands in as the prompt's caller and tracks in-flight turns. The agent sees a ref the session makes for each turn, never the caller's: the caller's ref cancels a turn but cannot answer it, start its work or message its process. The agent answers inline with Comm.reply/3, or with the new Comm.run_turn/3, which runs the work in a session-owned turn process (an owner that traps exits plus a linked worker; the worker also dies with the session).
  • {:acp_cancel, ref} kills that process (not just a dropped reply), answers the prompt {:error, :cancelled}, then forwards the cancel to update/2. Sent via Comm.call, it replies :ok only after the worker is dead ({:error, :not_found} when no turn has that ref). Late replies from a cancelled turn are dropped, so each prompt gets exactly one answer.
  • Comm.call/4 takes ref: so a caller knows the ref to cancel (owner chose option A). Comm.send_turn/3 messages a running turn.
  • Raxol.Agent.Reference.Counter (lib): increment, get, sensitive reset, and hold, which blocks until a release prompt or a cancel. Integrator decision: P4b1 introduces the Counter with only what this contract needs. P4a adds the plain Comm.call surface, the "journal continues, state fresh" moduledoc and :mcp_spawnable_apps.

Plan refs: TODO bullet "P4b1 agent turn contract"; review-3 R5 :1793, R4 P4b row :1524 (deterministic hold); review-2 H4 (approver installed as the turn's :tool_authorizer).

Sizing: lib +839/-28 at 29d40cd55 (git diff --shortstat 0c739138b 29d40cd55 -- packages/raxol_agent/lib), about 4.2x the +200 row and past the lane rule's 1.5x stop-and-report line (+776/-25, 3.9x, at e5b7f1387; +671/-25, 3.4x, at cb91e970c). 178 of those lines are the Counter, which the integrator moved in from P4a; without it +661. Most of the growth since the first push is review fixes (abnormal turn ends, owner checks, the agent ref, the review-4 monitor index) and their comments. The PR was kept whole as the integrator's call, pending the owner's decision on splitting it.

Adversarial review 2 (2026-10-25, three personas)

Fixes are new commits on this branch (ad3c8f8f1..999e9b648). RED lines were recorded against ad3c8f8f1 with the new tests or copies of the persona probes.

Finding Severity Disposition Fix commit RED / verification
Saboteur M1: a standalone session's lifecycle DOWN only cleared lifecycle_pid; turns never answered, workers ran on, turns leaked, Counter change_turn waited forever MEDIUM Fixed: on lifecycle DOWN every turn is answered {:error, :agent_down} and its process aborted; the Counter change turn monitors the agent ({:error, :agent_down} if it dies) d720dcb30, 7daf666ab RED: Task.await(held) timed out (** (EXIT) time out); probe: after lifecycle kill: worker alive true, turns 1, infinity caller after agent death (2s): nil; turns 1; asker alive true. GREEN: held caller result: {:ok, {:error, :agent_down}}; worker alive ...: false, infinity caller ...: {:ok, {:ok, {:error, :agent_down}}}; turns 0; asker alive false
Saboteur M2: the 5 s default Comm.call timeout cancels prompts MEDIUM Fixed per owner decision: Comm.call/2 refuses an {:acp_prompt, ...} with {:error, :timeout_required} without sending it; other messages keep 5 s. Every in-tree caller migrated (only this suite's prompt/3 helper used the default) fabb89be0 RED: left: {:error, :timeout_required} / right: {:error, :timeout}
NewHire 1: the caller-timeout test depended on the turn reaching its approver within 1 s MEDIUM Fixed: the session is :sys.suspended, so a zero timeout always expires before anything answers; on resume the session sees the prompt, then its cancel. The test asserts the stray {:error, :cancelled}, the turn gone, the agent's hold dropped and the approver never asked. A timeout that fires only once the worker is live cannot be forced without a test hook in Comm.call (the cancel comes from the caller's own timer); killing a live turn on cancel stays covered by the explicit-cancel tests 08b40cc1d NewHire's RED (1_000 to 1): no matching message after 5000ms ... {:authorizing, Counter.Hold, worker}. GREEN: --repeat-until-failure 20: 21 x 17 passed
Security L1: turn crash reasons logged verbatim (token=sk-live-123) LOW Fixed: turn and lifecycle failures are logged by shape (exception module or leading atom). The worker also catches its own raise, because the crash report of a crashed plain process logged the message as well d720dcb30, 999e9b648 Probe RED: failed: {%RuntimeError{message: "token=sk-live-123"}, ...}; crash-report probe: lines with the secret: 20 (of 20 raises). GREEN: failed: RuntimeError, lines with the secret: 0; the log test --repeat-until-failure 100: 101 x pass. The core Dispatcher debug log of prompts is not in this PR's diff and is left alone
NewHire 2: turn death not forwarded to update/2 LOW Fixed: a turn whose process crashes or is killed reaches update/2 as {:acp_cancel, ref}, as a cancel does; documented in the contract d720dcb30, 998f7b3de RED: right: {:ok, {:ok, "released 1", ...}}; GREEN: released 0
Security L2: forged {:command_result, {:counter_change, ...}} applied LOW Fixed: Counter records each change turn's session and applies a change only for a ref it still tracks (removed when applied, denied, or on {:acp_cancel, ref}) 7daf666ab Probe RED: after forged change, get -> {:ok, {:ok, "count: 0", ...}}; GREEN: count: 1
Security L3: metadata-form prompt (Comm.send(..., from:)) bypassed turn tracking LOW Fixed: the session starts a turn for that form too and keeps its metadata on the dispatch 68dba7739 RED: no matching message after 5000ms ... {:authorizing, Counter.Hold, worker} (probe: caller received: {:acp_run, ...}, cancel {:error, :not_found}); GREEN: probe cancel of that ref: {:ok, :ok}
NewHire 3: names and contracts to reverse-engineer LOW Fixed: run_turn/3/send_turn/3 take session; the dead send(asker, :approve) is deleted; a setup comment says why UserPreferences and Raxol.DynamicSupervisor run; the moduledoc and AGENT_FRAMEWORK.md say the reply shape is not checked at the boundary fa2f4c050, 08b40cc1d, 998f7b3de Docs and naming; covered by the suite
Saboteur L1: a turn message sent before run_turn registers is dropped LOW (inference) Not changed (not in this round's fix list)
Cross-PR X1: a sensitive Action loses its gate when bridged to MCP MEDIUM (cross-PR) agent_bridge.ex is not in this PR's diff; left to the integrator

Re-review (999e9b6, CONCERNS)

Fixed in 999e9b648..cb91e970c. Before results are the new tests run against 999e9b648's lib.

Finding Severity Disposition Fix commit Before / after
{:acp_turn_failed, ref} accepted from any sender; fail_turn assumed a turn process MEDIUM (new in review-2 fix) Fixed: the owner reports {:acp_turn_failed, ref, self()}, the session accepts it only when the report names the turn's owner pid (a claim, not a sender check: like the ref, the pid is not a secret; review 3 adds the agent ref) and aborts that process before answering; fail_turn guards the demonitor (if turn.mref) and documents that the process is down first d6087dee0 Before: turn not yet run, right: {:error, :agent_down} (session crashed, ArgumentError from demonitor); live hold, right: {:ok, {:ok, "released 0", ...}} (caller answered, hold dropped). After: session alive, worker alive, released 1, then released
L-2: call-form cancel via Comm.send(..., from:) not intercepted LOW Fixed: metadata clause added d6087dee0 Before: no matching message after 5000ms ... {:agent_reply, ^call_ref, :ok}. After: :ok, worker :killed, prompt {:error, :cancelled}
L-3: malformed prompt envelope reached update/2 and the logs with its text LOW Fixed: dropped in the session (both cast forms), logged by shape only d6087dee0 Before: no drop log, and the Dispatcher debug line carried hold token=sk-live-777. After: dropped a prompt ..., no secret in the log
Denied holds kept in the Counter model LOW Fixed: a denied hold turn tells the agent, which drops it efc5942ce Before: released 3 after 3 denied holds. After: released 0 (smoke: holds: MapSet.new([]) after 5)
L-1: a live ref is enough to apply a change; moduledoc claimed otherwise LOW Doc corrected (the ref is the only credential). Open: a per-turn secret token checked on the change message (not required this round) efc5942ce, cb91e970c Docs

Adversarial review 3 (2026-10-25, three personas)

Fixed in cb91e970c..e5b7f1387. RED lines are the new or changed tests run against cb91e970c's lib (git stash of packages/raxol_agent/lib): Result: 21/25 passed. One commit followed before review 4: 1920524e9 adds the Session moduledoc paragraph on the agent ref's in-node visibility (it travels in the Dispatcher's :turn_started event and the journal; the node is the trust boundary).

Finding Severity Disposition Fix commit RED / verification
Saboteur3 M + Security3 L1: a stray or forged {:agent_reply, ref, _} answered the caller and orphaned the turn's worker (not cancellable, not released); a forged {:acp_run, ref, fun} sent before the agent's own ran attacker work; {:acp_turn_message, ...} was relayed from anyone HIGH (promoted) Fixed: the agent gets a ref the session makes for each turn (agent_refs maps it to the caller's ref), so reply, acp_run, turn message and turn-failed messages are only accepted for that ref; a caller's ref, enough to cancel, matches nothing. The owner's reply is now {:acp_turn_reply, ref, owner, reply}, taken only when it names turn.pid. An inline Comm.reply while the turn's process runs aborts it and forwards {:acp_cancel, agent_ref}. Mechanism differs from the suggested pid-claim sender check: an inline reply is plain Comm.reply/3 ({:agent_reply, ref, r}, the same wire every Comm.call caller receives), so a sender claim there would change that public shape. Residual: the agent ref is in the Dispatcher's durable :turn_started event (it carries the prompt), so in-node EmitBus/journal readers can still learn it bebf3424c RED: forged reply left: {:ok, :ok} / right: {:ok, {:error, :not_found}} (cancel after the forged reply); forged acp_run no matching message after 5000ms ... {:authorizing, Counter.Hold, _worker}; inline reply while running no matching message after 5000ms ... {:DOWN, ^worker_mon, :process, ^worker, :killed}; forged turn-failed naming the real owner assert Process.alive?(worker) failed. GREEN: 25 passed, --repeat-until-failure 20: 21 x 25 passed
Saboteur3 L: a cancel is acknowledged while a change already sent still commits LOW Documented (Session and Comm moduledocs, Counter moduledoc, AGENT_FRAMEWORK.md, CHANGELOG): {:error, :cancelled} does not roll back a change the turn already sent to the agent bebf3424c Docs
NewHire3 L1: comment and PR body said turn-failed reports are trusted only from the turn's own process LOW Fixed: the comment says the report must carry the agent ref and name the owner pid, neither a secret from in-node readers (a forged report is no more than a cancel); the review-2 row above reworded bebf3424c Docs; the forged report naming the real owner is now a test (RED above)
NewHire3 L2: inert two-element {:acp_turn_failed, ref} sends; duplicate ensure_running/1 clauses LOW Fixed: the two-element sends deleted; the live-hold report test now names the real owner (found through the worker's link); ensure_running/1 is one clause bebf3424c, e5b7f1387 Suite
NewHire3 L3: stale sizing and TODO.md:981-984 LOW Fixed: sizing above recomputed and the kept-whole call recorded; the plan bullet cited by name (PR body)
NewHire3 L4: the Session moduledoc did not list the messages it intercepts LOW Fixed: the moduledoc lists the prompt, {:acp_cancel, ref} and the call-form cancel, and says an unmatched {:acp_cancel, x} is swallowed bebf3424c Docs
Security3 L2: the Dispatcher debug log still prints well-formed prompt text (lib/raxol/core/runtime/events/dispatcher.ex:482,494) LOW Open: not in this PR's diff; left to the integrator
Security3 X3: a forged Counter reply could reach an MCP client once P4a bridges agents cross-PR Reduced by the HIGH fix (a caller's ref can no longer answer a turn); the :turn_started residual above remains bebf3424c

Adversarial review 4 (2026-10-26, pre-merge)

Verdict CLEAN; the three LOWs are fixed in 1920524e9..29d40cd55. RED lines are the new test or a copy of the persona probe run against 1920524e9.

Finding Severity Disposition Fix commit RED / verification
Security4: a forged {:DOWN, _, :process, lifecycle_pid, _} (any ref) answered every turn {:error, :agent_down}, killed their workers and nulled lifecycle_pid for good (next prompt :no_dispatcher); only the public lifecycle pid was needed LOW Fixed: the session keeps the lifecycle monitor ref (lifecycle_mref, set only when it owns and monitors the lifecycle) and matches the agent-down clause on it (when is_reference(mref), so a forged nil ref on a tree-owned session matches nothing either). New test: a forged DOWN with another ref, and one with nil, on a supervised and on a standalone session leave the held turn running, then release answers released 1 f5d42c598 RED: Expected truthy, got false / code: assert Process.alive?(worker) (turn_contract_test.exs:289). Probe after: turn worker alive after forged DOWN? true; lifecycle really alive? true, lifecycle_pid now: #PID<0.672.0>; turn still tracked? true, caller {:ok, {:ok, "count: 1", ...}}. The real agent-down test (standalone, lifecycle killed) still answers {:error, :agent_down}
Saboteur4: many turns ending at once cost quadratic time (Enum.find over all turns per DOWN; PromptTurn.abort/2 and demonitor(..., [:flush]) each scanned a mailbox full of DOWNs) LOW Fixed: monitors indexes each turn's caller monitor and owner monitor to its ref, so a DOWN finds its turn in O(1); abort/2 waits on a monitor made just before its receive (the receive skips older messages); turn monitors are removed without :flush, and a DOWN whose monitor left the index falls to the catch-all. Behaviour unchanged: existing suite, no timing assert 51698fb0a Scale smoke (/tmp/saboteur4-turns-scale.exs copy, Counter hold turns, all callers killed), same machine: before n=1000 65 ms, n=4000 478 ms, n=16000 9380 ms; after 39 ms, 128 ms, 484 ms; agent holds=0 agent_refs=0 each. Index only (no abort/flush change) measured 57, 364, 3346 ms. Mixed-load probe after: session turns=0 agent_refs=0 monitor_index=0 monitors=0 msgq=0, PromptTurn procs alive=0
NewHire4 L1: two unrelated cancel protocols named acp_cancel LOW Fixed (no rename): the Session and Comm moduledocs and the CHANGELOG say the turn contract's {:acp_cancel, ref} is not the bare :acp_cancel atom that Raxol.AgentClientProtocol.Session sends to a Raxol.Agent.ClientProtocol.TurnRunner process, and that the session neither sends nor intercepts that atom 29d40cd55 Docs
NewHire4 L2: the PR body stopped at e5b7f1387 LOW Fixed: review 3 above names 1920524e9, sizing and evidence are at 29d40cd55 (PR body)
NewHire4 note: Manual testing under MIX_ENV=test gets {:error, :registry_not_running} from start_session LOW Fixed: Manual testing says to run in dev (iex -S mix) (PR body) NewHire ran steps 1-3 in dev: all passed

Evidence

Refreshed at 999e9b648 (review-2 fixes); review-4 checks at 29d40cd55, review-3 checks at e5b7f1387 and re-review checks at cb91e970c listed first. All commands were run in packages/raxol_agent unless noted.

  • Review 4 at 29d40cd55: mix format --check-formatted clean; MIX_ENV=test mix compile --warnings-as-errors and mix compile --warnings-as-errors (dev) exit 0, root MIX_ENV=test mix compile --warnings-as-errors exit 0; mix credo --strict on touched files only the old stop_emit_bridge nesting; turn_contract_test.exs --repeat-until-failure 20: 21 x 26 passed; turn_contract + comm + session + session_supervisor + turn: Result: 71 passed; mix test: Result: 3028 passed (19 properties, 3009 tests), 53 excluded; prose lint exit 0; scripts/check-quality-ratchet.sh (root): IMPROVED credo: 1169 < baseline 1182 (-13), IMPROVED dialyzer: 106 < baseline 118 (-12), exit 0. f5d42c598 and 51698fb0a each compile and pass turn_contract_test.exs (26 passed; 45 with session_test.exs). No root lib/ change, so no mix raxol.check
  • Review 3 at e5b7f1387: mix format --check-formatted clean; MIX_ENV=test mix compile --warnings-as-errors --force clean (package) and root MIX_ENV=test mix compile --warnings-as-errors exit 0; mix credo --strict on touched files only the old stop_emit_bridge nesting; turn_contract_test.exs --repeat-until-failure 20: 21 x 25 passed; mix test: Result: 3027 passed (19 properties, 3008 tests), 53 excluded; prose lint exit 0; scripts/check-quality-ratchet.sh (root): IMPROVED credo: 1169 < baseline 1182 (-13), IMPROVED dialyzer: 106 < baseline 118 (-12), exit 0. No root lib/ change, so no mix raxol.check
  • Re-review at cb91e970c: mix format --check-formatted clean; mix compile --warnings-as-errors clean; mix credo --strict on touched files only the old stop_emit_bridge nesting; turn_contract_test.exs --repeat-until-failure 20: 21 x 22 passed; mix test: Result: 3024 passed (19 properties, 3005 tests), 53 excluded; prose lint exit 0; d6087dee0 alone compiles and passes its 21 turn tests
  • mix compile --warnings-as-errors (MIX_ENV=test): clean; root MIX_ENV=test mix compile --warnings-as-errors: Generated raxol app
  • mix format --check-formatted: clean
  • mix credo --strict on the changed files: only session.ex stop_emit_bridge nesting, which was already there at ad3c8f8f1 (unchanged code)
  • scripts/check-quality-ratchet.sh --gate credo (root): IMPROVED credo: 1169 < baseline 1182 (-13)
  • scripts/check-quality-ratchet.sh --gate dialyzer (root): IMPROVED dialyzer: 106 < baseline 118 (-12)
  • mix test: Result: 3019 passed (19 properties, 3000 tests), 53 excluded. Two earlier full runs each had unrelated failures (StorageInvariantsTest I8, Journal.ChainTest damage, PermissionWireTest reject; none touch the turn code); those three files then passed 6 x --repeat-until-failure, and the final run above is green
  • mix test test/raxol/agent/turn_contract_test.exs --repeat-until-failure 20: 21 x 17 passed; each intermediate commit compiles with --warnings-as-errors and passes the suite as it stands at that commit
  • Prose lint (git ls-files -z '*.md' | xargs -0 elixir scripts/prose_lint.exs): exit 0

New tests (test/raxol/agent/turn_contract_test.exs, Counter started via Session.Supervisor.start_session/2, unique ids, tmp journal dir):

  1. Prompt round trip returns {:ok, "count: N", usage}.
  2. The approver is honoured: deny_all and deny_sensitive refuse reset (count unchanged), allow_all grants it.
  3. Hold, then Comm.call(id, {:acp_cancel, ref}) returns :ok. The worker (found through the approver) goes DOWN :killed, the prompt gets {:error, :cancelled}, a second cancel gets :not_found, and the next prompt succeeds.
  4. Hold, then a release prompt: the hold replies released and the worker exits :normal.

Review-2 tests (same file): a prompt without a timeout is refused (:timeout_required); the caller-timeout cancel, made deterministic with :sys.suspend; a failed turn logs the reason's shape, not its content; a turn killed from outside answers :turn_failed and the agent drops it; a standalone session answers its held turn {:error, :agent_down} and kills the worker when its lifecycle is killed; a forged change no live turn sent is dropped; a prompt sent with from: is a session-owned, cancellable turn.

Smoke (throwaway script, mix run --no-start, real Registry/DynSup, start_session/2):

start_session -> #PID<0.1830.0>; whereis -> #PID<0.1838.0>
prompt increment -> {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
hold in flight: worker #PID<0.1842.0> alive? true
acp_cancel -> {:ok, :ok}
worker DOWN reason: :killed
held prompt reply -> {:ok, {:error, :cancelled}}
prompt increment -> {:ok, {:ok, "count: 2", %{input_tokens: 0, output_tokens: 0}}}
prompt reset (deny_sensitive) -> {:ok, {:ok, "[Tool error for reset]: denied (sensitive_tool)", %{input_tokens: 0, output_tokens: 0}}}
stop_session -> :ok

Review-2 smoke (throwaway script, MIX_ENV=test mix run --no-start, real Registry/DynSups, start_session/2 plus a standalone Session.start_link; debug/info lines filtered):

prompt without timeout -> {:error, :timeout_required}
prompt increment, 5_000 -> {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
metadata-form hold: worker #PID<0.434.0> alive? true
acp_cancel of it -> {:ok, :ok}
metadata-form caller got -> {:error, :cancelled}
[warning] [Agent.Session] turn #Reference<...> failed: :killed
hold worker killed from outside -> {:ok, {:error, :turn_failed}}
release after the failed hold -> {:ok, {:ok, "released 0", %{input_tokens: 0, output_tokens: 0}}}
after forged change, model -> {:ok, %{count: 1, changes: %{}, holds: MapSet.new([])}}
[warning] [Agent.Session] turn #Reference<...> failed: RuntimeError
raising approver -> {:ok, {:error, :turn_failed}}
standalone lifecycle killed, :infinity caller -> {:ok, {:error, :agent_down}}; worker alive? false
[warning] [Agent.Session] Lifecycle for "s2" crashed: :killed
cancel after agent down -> {:ok, {:error, :not_found}}

Re-review smoke (MIX_ENV=test mix run --no-start, same setup):

forged turn-failed on live hold: barrier {:ok, {:error, :not_found}}; worker alive? true
release -> {:ok, {:ok, "released 1", ...}}; held -> {:ok, {:ok, "released", ...}}
forged turn-failed, turn not run: barrier {:ok, {:error, :not_found}}; session alive? true
metadata call-cancel answered -> :ok
its prompt -> {:error, :cancelled}
[warning] [Agent.Session] "c1" dropped a prompt whose caller is not a pid or whose ref is not a reference   (x2)
after malformed envelopes, release -> {:ok, {:ok, "released 0", ...}}
after 5 denied holds, model -> {:ok, %{count: 0, changes: %{}, holds: MapSet.new([])}}

Review-3 smoke (MIX_ENV=test mix run --no-start /tmp/fix1301r3-smoke.exs, real Registry/DynSup, start_session/2; forgeries use the caller's ref):

S1 barrier: {:ok, {:error, :not_found}}
S1 caller answered by forgeries? (nil = no): nil
S1 hold worker alive after forgeries: true
S1 cancel with the caller's ref: {:ok, :ok}
S1 hold worker alive after cancel: false
S1 caller got: {:ok, {:error, :cancelled}}
S1 release (agent saw the cancel): {:ok, {:ok, "released 0", %{input_tokens: 0, output_tokens: 0}}}
S2 barrier: {:ok, {:error, :not_found}}
S2 agent's own hold turn ran: true
S2 cancel: {:ok, :ok}
S2 caller got: {:error, :cancelled}
S2 forged work never ran
S3 increment: {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
S3 get (inline reply): {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
S3 reset allowed: {:ok, {:ok, "count: 0", %{input_tokens: 0, output_tokens: 0}}}
S3 release: {:ok, {:ok, "released 1", %{input_tokens: 0, output_tokens: 0}}}
S3 held caller got: {:ok, {:ok, "released", %{input_tokens: 0, output_tokens: 0}}}

Review-4 smoke (dev, mix run, real Registry/DynSup, start_session/2; scripts are copies of the persona probes). Manual-testing steps 1-3 (/tmp/newhire4-counter.exs copy):

start_session: {:ok, #PID<0.599.0>}
increment, 5_000: {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
increment, no timeout: {:error, :timeout_required}
reset deny_sensitive: {:ok, {:ok, "[Tool error for reset]: denied (sensitive_tool)", ...}}
get: {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
cancel call: {:ok, :ok}
held task: {:ok, {:error, :cancelled}}
next prompt: {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}

Forged agent-down (/tmp/security4-turn_probe_test.exs copy, run as a package test; the held turn is approved after the checks):

§ turn worker alive after forged DOWN? true; lifecycle really alive? true
§ session lifecycle_pid now: #PID<0.672.0>; turn still tracked? true
§ caller got -> {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}
§ next prompt -> {:ok, {:ok, "count: 1", %{input_tokens: 0, output_tokens: 0}}}

Scale and mixed load (/tmp/saboteur4-turns-scale.exs, /tmp/saboteur4-turns.exs copies):

n=1000: all running after 31 ms; all callers killed -> turns 0 after 39 ms; agent holds=0 agent_refs=0
n=4000: all running after 86 ms; all callers killed -> turns 0 after 128 ms; agent holds=0 agent_refs=0
n=16000: all running after 381 ms; all callers killed -> turns 0 after 484 ms; agent holds=0 agent_refs=0
2000 callers done in 2008 ms
session turns=0 agent_refs=0 monitor_index=0 monitors=0 msgq=0
counter model: count=3 holds=0 changes=0
PromptTurn procs alive=0  process delta=2
get after load: {:ok, "count: 3", %{input_tokens: 0, output_tokens: 0}}

Model spend: none.

Manual testing

Run these in dev: iex -S mix in packages/raxol_agent. Under MIX_ENV=test nothing starts Raxol.Agent.Registry (the suites start their own), so start_session returns {:error, :registry_not_running}.

  • Start the Counter with Session.Supervisor.start_session(Raxol.Agent.Reference.Counter, id: "c1", session_id: "c1") and check that Comm.call("c1", {:acp_prompt, "increment", ToolPolicy.allow_all()}, 5_000) returns {:ok, {:ok, "count: 1", _}}, and that the same call without a timeout returns {:error, :timeout_required}
  • Send hold from a Task with Comm.call/4 ref: ref, then Comm.call("c1", {:acp_cancel, ref}): the Task gets {:error, :cancelled} and the next prompt answers
  • Send reset with ToolPolicy.deny_sensitive(): the reply says denied and get shows the count unchanged
  • Start a standalone Session.start_link(app_module: Raxol.Agent.Reference.Counter, id: "s2", session_id: "s2"), hold a turn from a Task with :infinity, kill the lifecycle (Registry.lookup(Raxol.Agent.Registry, {:lifecycle, "s2"})): the Task gets {:ok, {:error, :agent_down}} and the hold's worker is dead
  • Kill a held turn's worker from outside: the Task gets {:error, :turn_failed} and release answers released 0
  • A prompt whose approver raises "token=..." logs failed: RuntimeError and no line with the token
  • Comm.send("c1", {:call, self(), ref, {:acp_prompt, "hold", approver}}, from: "editor") runs the hold in a session-owned worker (no {:acp_run, ...} reaches the sender) and {:acp_cancel, ref} kills it
  • To the session of a held turn (caller's ref), send {:agent_reply, ref, {:ok, "forged", %{}}}, {:acp_turn_message, ref, :release} and {:acp_turn_failed, ref, owner} ({:links, [owner]} = Process.info(worker, :links)): the Task is not answered, the hold keeps running, and Comm.call("c1", {:acp_cancel, ref}) still answers :ok and the Task {:error, :cancelled}
  • Suspend the agent's dispatcher, Comm.send a hold prompt with ref, send the session {:acp_run, ref, fn -> ... end}, resume: the forged function never runs and the agent's own hold does
  • Five hold prompts with ToolPolicy.deny_all(:x), then release: released 0
  • Hold a turn on "c1", then send its session {:DOWN, make_ref(), :process, lifecycle, :forged} ([{lifecycle, _}] = Registry.lookup(Raxol.Agent.Registry, {:lifecycle, "c1"})): the hold keeps running and release answers released 1

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Scoreboard

Note: master has no record for this PR's base 0c73913; the nearest is 1 commit earlier, so changes in those commit are included below.

This change makes keystroke work as a share of drawing the whole first frame 0.7% higher (worse) (1 metric is new and not compared).

Metric master this PR Change
Frame bytes per keystroke (table demo, 120x40) 1,195.6 bytes 1,195.6 bytes unchanged
Work per keystroke (table demo, BEAM reductions) 1,726,399 reductions 1,733,537 reductions unchanged
Keystroke work as a share of drawing the whole first frame (table demo) 0.974 of a first frame 0.981 of a first frame +0.7% (worse)
Frame bytes for the first frame (table demo, 120x40) 5,918 bytes 5,918 bytes unchanged
Work to draw the first frame (table demo, BEAM reductions) 1,771,829 reductions 1,766,784 reductions unchanged
Frame bytes for the first frame (flex layout demo, 120x40) 6,537 bytes 6,537 bytes unchanged
Work to draw the first frame (flex layout demo, BEAM reductions) 749,559 reductions 749,559 reductions unchanged
Memory per cell of the screen the engine holds (table demo, 120x40, shared heap) - 90.08 bytes new
Memory per screen cell, 80x24 buffer (shared heap) 240.25 bytes 240.25 bytes unchanged
Memory per screen cell, 80x24 buffer (flat copy) 456.29 bytes 456.29 bytes unchanged
Memory per screen cell, 200x100 buffer (shared heap) 240.08 bytes 240.08 bytes unchanged
Memory per screen cell, 200x100 buffer (flat copy) 456.09 bytes 456.09 bytes unchanged
Memory per screen cell, 500x500 buffer (flat copy) 456.03 bytes 456.03 bytes unchanged

master: 48bb721 on Elixir 1.20.2, OTP 29.0.3, x86_64-pc-linux-gnu. this PR: 29d40cd on Elixir 1.20.2, OTP 29.0.3, x86_64-pc-linux-gnu. Changes within a metric's noise tolerance count as unchanged.

@DROOdotFOO

Copy link
Copy Markdown
Owner Author

Adversarial review

Reviewer: a sub-agent with three personas (Saboteur, New Hire, Security Auditor), reviewing head 9aaba3d0d against origin/master. Repros use a real Session.Supervisor, the real Counter and a tmp journal dir.
Verdict: CONCERNS (2 HIGH, 3 MED, 5 LOW). After fixes, the new head is ad3c8f8f1 ("Cancel abandoned turns, ask approvers in turn").

id sev finding status evidence
H1 HIGH Turns were never cleaned up. After a caller timeout, a caller death, a non-contract agent or a raising approver, the worker kept running and the turns entry leaked. fixed The session monitors the prompt caller, and a DOWN cancels the turn. Comm.call/4 casts {:acp_cancel, ref} on a prompt timeout. Repros: A {true,[ref]} -> {false,[]}, K {true,1} -> {false,0}, F 3 leaked -> 0. New tests: "a prompt whose caller times out is cancelled" and "a prompt whose caller goes down is cancelled".
H2 HIGH The approver ran inline in the Dispatcher, which caused three faults. A blocked ask crashed the session on get_model (5 s call). A re-entrant approver deadlocked. A cancel during the ask was acked, yet reset still landed afterwards. fixed (owner decision 2026-10-24) Approver-gated actions run in the run_turn process, never inline. Counter's increment/reset ask in the turn and send the change back to update/2. Repros: B count: 1 is kept after cancel plus approval; C re-entrant call answers; J get_model -> {:ok, %{count: 0}}; L raising approver -> {:error, :turn_failed} with no leak; E2 checkpoint after stop mid-ask -> {:ok, 4} (was :mid_turn). Tests B, J and L were added.
M1 MED Comm.send(id, {:acp_cancel, ref}, from: x) never cancelled. fixed Added the metadata-tuple clause. Test: "a cancel sent with from: kills the held turn".
M2 MED Cancel is an untrappable :kill with no grace period. Ports or OS processes started by a turn are orphaned. deferred The Counter is unaffected. Flagged for P4a/P4b2 (grace message or Interrupt).
M3 MED The journal brackets the Dispatcher fold rather than the ACP turn, and no turn_canceled event exists. deferred P4a/P4b2 own turn journaling. Note that cancel leaves no open turn_started; E1 checkpoint gives {:ok, 10}.
L1 LOW Inner error atoms were undocumented. fixed comm.ex moduledoc and AGENT_FRAMEWORK.md now list :invalid_prompt, :duplicate_ref, :no_dispatcher, :turn_failed, :cancelled and :not_found.
L2 LOW ref: was not validated. fixed call/4 raises ArgumentError on a non-reference. The docs say refs must be fresh make_ref/0 values.
L3 LOW Counter's release skips the approver. accepted It is a reference agent.
L4 LOW A cross-process cancel can overtake the prompt (no tombstone). deferred P4b2's born-cancelled latch covers ACP.
L5 LOW Size is 1.84x the sizing row, or 1.25x without Counter. accepted Integrator decision: Counter lives here.

The false comment at session.ex cancel and the comm.ex wording were corrected.

Still open:

  • A cancel that arrives after the change was already sent to the agent: the change still applies. The cancel reply follows the commit.
  • A stray {:error, :cancelled} can land in a timed-out caller's mailbox (documented).

Verification at ad3c8f8f1, package raxol_agent:

  • mix test: 3013 passed.
  • turn_contract_test.exs 20x: 11 passed each run.
  • format and compile --warnings-as-errors: clean. Credo on touched files: 1 pre-existing nesting issue in unchanged code.
  • Prose lint: clean. Ratchet: credo 1169 < 1182, dialyzer 106 < 118.

The test depended on the turn reaching its approver inside the 1 s
Comm.call timeout. With the session suspended, a zero timeout expires
before anything can answer, so the session always sees the prompt and
then its cancel: the turn is answered {:error, :cancelled}, the agent
drops it, and its approver is never asked. Killing a live turn on cancel
stays covered by the explicit-cancel tests.

Also drop a dead send to an already-killed asker and say why the setup
starts UserPreferences and Raxol.DynamicSupervisor.
A Comm.call timeout cancels the turn, so the 5 s default would kill
any prompt that runs longer. Comm.call/2 now refuses an
{:acp_prompt, text, approver} with {:error, :timeout_required} without
sending it; other messages keep the 5 s default (owner decision
2026-10-25).
Comm.send(id, {:call, pid, ref, {:acp_prompt, ...}}, from: ...) reached
update/2 untracked, so the agent handed its turn function to the sender:
no caller monitor, no cancel, no prompt validation. The session now
starts a turn for that form too, keeping the metadata (from, causation)
on the dispatch.
Both take the pid in the prompt's caller position, which is always the
session standing in for the real caller; calling it caller misled.
A standalone session whose lifecycle went down only cleared
lifecycle_pid: its turns were never answered and their processes ran
on. It now answers each turn {:error, :agent_down} and aborts its
process. A turn whose process crashes or is killed from outside is
answered {:error, :turn_failed} and reaches update/2 as
{:acp_cancel, ref}, as a cancel does, so the agent drops what it tracks
for the turn. Turn and lifecycle failures are logged by shape
(exception module or leading atom), never by content.
The reference agent applied any {:counter_change, ...} it received, so
any process could change the count without an approved turn. It now
records the session for each change turn it starts and applies a change
only for a ref it still tracks, forgetting it once applied, denied, or
cancelled or failed ({:acp_cancel, ref}). The change turn also monitors
the agent, so it ends {:error, :agent_down} instead of waiting forever
for a count from a dead agent. Counter names the prompt's session pid
session, like Comm.run_turn/3.
Comm moduledoc, AGENT_FRAMEWORK.md and the CHANGELOG entry: the
required prompt timeout, metadata-form prompts, {:acp_cancel, ref} on
any turn that ends without its reply, {:error, :agent_down}, Counter's
tracked changes, and that the reply shape is not checked at the
boundary.
A turn whose work raised let its worker crash, and the emulator's crash
report logged the exception message (observed: token=sk-live-123 in 20
of 20 raises). The worker now catches the failure and reports only its
shape (exception module, or exit tag) to the owner, which logs that and
fails the turn as before.
{:acp_turn_failed, ref} was taken from any sender, and fail_turn
assumed the turn had a process: a forged report crashed the session for
a turn not yet run (demonitor of nil), and on a live hold answered the
caller while the worker ran on, orphaned. The owner now reports
{:acp_turn_failed, ref, self()}, the session accepts it only from the
turn's own process and aborts it before answering, and fail_turn skips
the demonitor when there is no process.

Re-review LOWs in the same place: a call-form cancel sent with
Comm.send(..., from:) is now handled like the bare form, and a prompt
envelope whose caller is not a pid or whose ref is not a reference is
dropped in the session (logged by shape) instead of reaching update/2
and the logs with its text.
A hold the approver denied stayed in the model, so holds piled up and
release counted them (released 5 after five denied holds). The denied
hold turn now tells the agent, which drops it. The moduledoc no longer
claims no other process can change the count: a live turn's ref is the
only credential, and whoever knows it can still send that turn's change.
The agent now sees a ref the session makes for each turn, never the
caller's, so a process that knows the caller's ref (enough to cancel)
can no longer answer the turn, start its work or message its process.
The turn's owner reports its reply as {:acp_turn_reply, ref, owner,
reply}, checked against the owner pid. An inline reply while the
turn's process runs kills that process first and forwards
{:acp_cancel, ref} to the agent.

Also documents the reserved message shapes the session intercepts and
that a cancel does not roll back a change already sent to the agent.
No code handles the two-element {:acp_turn_failed, ref} any more, and
the two ensure_running/1 clauses did the same thing.
@DROOdotFOO
DROOdotFOO merged commit 1f41c9a into master Oct 27, 2026
52 of 53 checks passed
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