Journal bounded turn deadlines and remote stop intent - #1744
Conversation
f740439 to
6c885c7
Compare
BinaryBourbon
left a comment
There was a problem hiding this comment.
Review: the journal itself is good; two things need answering before it becomes the base
I reviewed this in a worktree off pr-1744 against its own isolated database (mix deps.get, ecto.create, ecto.migrate, targeted probes). CI on 6c885c7b is green across all six partitions, static analysis, coverage and the release/contract job; only CI required is still pending. The state machine is careful work — the lock ordering (machine → parent → journal → turn), reading the clock after the locks, Audit.record outside the transaction, and the immutability checks in TurnExecution.changeset/2 are all right, and the 30 regressions genuinely cover them.
Requesting changes on two points, both about what this schema commits the next eight PRs to rather than about the code as written.
1. awaiting_identity and uncertain are absorbing states, and one of them takes the owner's escape hatch with it
Nothing in this PR — or anywhere in the rest of the stack, I checked all nine — ever revisits a row in awaiting_identity or uncertain:
_unsafe_due/2selectsstate == "active" and deadline_at <= now, orstate == "ready"._unsafe_recover_submissions/2selectsstate == "submitted"only._unsafe_claim_termination/2refuses anything that is not"ready"._unsafe_record_termination/4needs theattempt_id, and refusesuncertainunlesslast_error == "termination_unconfirmed".
Both states are in @fenced, and both are outside ["completed", "stopped"], so while a row sits in one of them _unsafe_fenced?/1 is true, open_execution?/1 refuses registration, and _unsafe_sandbox_open?/1 makes reset_sandbox/2 return :sandbox_mid_turn — permanently, with no timeout, no retry and no operator override.
Two probes against this branch, both pass, i.e. both dead-ends are real:
# spawn intent recorded, provider never reports a session id
{:ok, _} = ExecutionGuard._unsafe_claim_spawn(ex.id, now: now)
{:ok, _} = ExecutionGuard._unsafe_expire(ex.id, now: deadline)
assert Repo.get!(TurnExecution, ex.id).state == "awaiting_identity"
assert ExecutionGuard._unsafe_due(DateTime.add(deadline, 86_400)) == []
assert ExecutionGuard._unsafe_recover_submissions(DateTime.add(deadline, 86_400)) == []
assert {:error, :execution_fenced} =
ExecutionGuard._unsafe_register(successor.id, Ecto.UUID.generate(), later)
assert {:error, :sandbox_mid_turn} = Conversations.reset_sandbox(ctx.home) # forever# two identities on one connection
{:ok, _} = ExecutionGuard._unsafe_bind_identity(ex.id, conn, "sess1")
{:ok, _} = ExecutionGuard._unsafe_bind_identity(ex.id, conn, "sess2")
# => state "uncertain", last_error "conflicting_identity"
assert {:error, :not_ready} = ExecutionGuard._unsafe_claim_termination(ex.id)
assert {:error, :stale_attempt} = ExecutionGuard._unsafe_record_termination(ex.id, nil, :ok)
assert {:error, :sandbox_mid_turn} = Conversations.reset_sandbox(ctx.home) # foreverThis is not hypothetical once the stack lands. ExecutionDeadlineWorker in #1746 rescues any failed provider call to {:error, :termination_unconfirmed} and records it, and its tasks carry a 10 s :timer.kill_after — so every provider termination that fails or is slow produces an uncertain row that nothing ever retries, and the conversation plus its shared home are fenced for good. On a persistent home under ADR 0023 that is every conversation on the machine, not just the bounded one.
reset_sandbox/2 is the documented owner-facing recovery for exactly this class of problem (#1071). Making it refusable-forever from a journal the owner cannot see or clear is the part I would not want to discover in production.
What I think this needs before the base lands, in rough order of preference:
- a bounded obligation:
awaiting_identityanduncertaincarry an age, and past it the row retires tostoppedwith the uncertainty recorded on the turn — the fence exists to stop a replay, and after the sandbox is gone there is nothing left to replay onto; - failing that, an explicit operator lever (an admin release, or
reset_sandbox(force: true)that retires the journal alongside the machine) and an admin surface that shows why a home will not reset; - at minimum, name this in ADR 0046. The "Required integration and acceptance" list covers retention and account deletion but says nothing about a row that never resolves, and the state table describes
uncertainas "retain the fence" without saying for how long.
2. reset_sandbox/2 changes behaviour today, and the duration it bills no longer matches the row
Terminating inside the transaction is the right call for the fence, but _unsafe_retire_home/1 still runs afterwards and still writes the row. Since update_sandbox/2 re-reads current under FOR UPDATE (#1761), the second write now finds status == "terminated", so record_sandbox_usage/2 no-ops — no double metering, good — but it does still bump terminated_at to the post-destroy clock, after the usage row was computed from the pre-destroy one. Measured on this branch with a 1.2 s stubbed destroy:
inserted_at=~U[2026-09-11 04:35:05Z] terminated_at=~U[2026-09-11 04:35:06Z]
usage duration_ms=0
actual span ms=1000
On main both come from the same post-destroy timestamp and agree. It is a small absolute error (one provider call per reset), but sandbox_terminated.duration_ms is what a provider bill is reconciled against, and having it disagree with the row's own terminated_at is the kind of drift that is painful to explain later. Either pass the post-destroy stamp through, or stop the retire helper writing the row on this path — right now that half of _unsafe_retire_home/1 is dead on the reset path while its docstring still claims to do it.
Also worth a line in the docstring: record_sandbox_usage/2 (a usage_events insert plus the PostHog mirror) now runs inside the transaction that holds pg_advisory_xact_lock(4316, …). It is cheap and Sink.enqueue does not block, but the rule elsewhere in this codebase is that nothing under a process-wide advisory lock waits on anything, and this is a new write under it.
Smaller things, none blocking
_unsafe_write_turn/3costs aturn_executionslookup on every turn write.Repo.get_by(TurnExecution, turn_id: turn.id)runs before every_unsafe_update_turn/2— includingPending.ex:91, which fires on every permission request of every turn. Indexed and cheap, but it is unconditional on the hottest write path in the system and worth a sentence saying so.maybe_put_reply_text/2moved fromget_change(:status)toget_field(:status). I follow why (the guard drops:statusfromattrswhen fenced, soget_changewould miss it), but it changes the unbounded path too: a turn that is already terminal withreply_text: nilwill now re-run_unsafe_turn_reply_text/1— a conversation lookup plus every log event for the turn plus block assembly — on each later update, where before it ran once. The comment that used to explain "the write that materialises the reply, not every later update" was deleted with it; the new behaviour deserves its own.valid_session_id?/1is~r/\A[A-Za-z0-9_-]+\z/. That refuses a dot or a colon, which several session-id formats use. If the restriction is deliberate (it reads like path-traversal defence, and the test uses"../other"), say so; otherwise this will reject a legitimate identity at bind time and land the execution in theuncertaindead-end above.decisions/0046says 4,632 tests; onmaintoday that number has moved. Not worth chasing, but the ADR'sstale_after: 2026-09-14is three days out — it will need a refresh before this merges regardless.
On the stack
For the record, since this is the base: #1745 and everything above it are forked from 59c5ebc8 and do not contain this PR's head commit. Since that fork point main has landed the execution-limit admission campaign (#1787–#1793), which ships its own Fountain.Conversations.ExecutionLimits, users.execution_limits and an execution_allowances table. #1745 adds a second, earlier-timestamped migration for the same users.execution_limits column. I have put the detail on #1745; it does not change anything here, but this PR cannot be merged as "the base" of the stack until the stack is actually rebased onto it.
Happy to re-review quickly once the fence has a way out.
…sentence Review of #1744 found two ways the journal could not be escaped. `awaiting_identity` and `uncertain` were absorbing states. Nothing revisited them: a claim needs `ready`, an acknowledgment needs the `attempt_id` of an attempt whose owner is gone, and the due/recovery scans select neither. Both states keep `_unsafe_fenced?/1` true and both make `reset_sandbox/2` answer `:sandbox_mid_turn`, so one provider that never named its session, or never acknowledged a stop, cost the owner both recoveries that exist for exactly that — a new turn, and the reset — permanently and with no operator lever. Two bounded exits, neither of which authorizes a provider write: * `_unsafe_retire_unresolved/2` ages a row out of either state. It keeps `last_error`, so the trail still says the operation was never confirmed; the obligation is written off, not erased. A session that really did survive is the `SandboxReaper`'s to find, like every unbounded turn's. * `reset_sandbox(force: true)` resets past an unresolved execution and retires the journals bound to that machine with it. It does not override a running turn. Once the sandbox row is terminated in the same transaction, `current_binding?/1` already makes every attempt unreachable, including one aimed at a replacement built under a reused name — so recording the destroy as the termination it is is honest rather than optimistic. Separately, retiring the row inside the reset transaction left `_unsafe_retire_home/1` stamping `terminated_at` again after the provider call, while the `sandbox_terminated` usage row had already derived `duration_ms` from the earlier stamp. Measured against a 1.2s destroy the two disagreed by 1000ms, and that row is what a provider bill is reconciled against. The helper no longer passes its own timestamp: a caller that already retired the row keeps its stamp, and `stamp_terminated_at/1` fills the gap for one that did not. Also documents three things the review had to work out from the code: that `_unsafe_write_turn/3` costs one indexed lookup on every turn write and why that is deliberate, why `maybe_put_reply_text/2` reads `get_field` rather than `get_change` and what that changes on the unbounded path, and why `valid_session_id?/1` is narrower than what a provider might send. ADR 0046 records the ageing exit and the owner's lever in the decision rather than leaving them to integration, and the deadline supervisor's row now names the sweep it owes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
|
Pushed 1. The fence now has two exits, neither of which authorizes a provider write.
On why forcing is safe rather than merely permitted: the row is terminated in the same transaction, and 2. Also addressed, as comments rather than behaviour: why Verification. Nine new regressions. Each of the three fixes was reverted in turn to confirm its test fails: the age-out reverts to 4 failures, The stack note stands: #1745 and up are forked from |
BinaryBourbon
left a comment
There was a problem hiding this comment.
Reviewed c02166253087d4eccef94c2cae66abf415fb421c (the head when I started; if it has moved since, this covers that SHA only). CI is green on it across all six partitions, static analysis, coverage, the release/contract job and CI required.
Both blockers are answered. Approving.
The absorbing states now have exits, and neither of them authorizes a provider write. _unsafe_retire_unresolved/2 is the first preference I asked for — an obligation with an age. It writes awaiting_identity and uncertain off to stopped, keeps last_error so the trail still says the operation was never confirmed, and the four new regressions cover the two entry paths, the release of both the conversation fence and the machine fence, and the sweep leaving completed/stopped alone. The property that convinces me it is safe rather than merely convenient is one the tests do not name: a retired row is stopped, not completed, so prior_connection/1 in _unsafe_register still refuses :connection_retired on that connection id. Giving up on the obligation does not hand the same connection to a later turn — it only stops the conversation being hostage to it. That is the right line.
reset_sandbox(force: true) is the second, and the reasoning in its docstring holds: the sandbox row is terminated in the same transaction that retires the journals, current_binding?/1 requires a live row of the same id, name and provider, so no attempt can reach a replacement built under a reused name. It correctly still refuses a running turn — _unsafe_running_turns_elsewhere/2 is outside the force? guard — and sandbox_reset_test.exs asserts exactly that. _unsafe_retire_sandbox/1 runs inside the caller's transaction and records nothing; audit_retired/1 writes the trail after commit, which is ADR 0013 followed rather than cited.
terminated_at and duration_ms agree, and the fix is real rather than cosmetic. _unsafe_retire_home/1 no longer passes its own stamp; the reset's in-transaction stamp survives, and the second update_sandbox/2 finds nothing to change. I checked the other caller: _unsafe_destroy_home/1 (agent delete, conversations.ex:2948) does not pre-stamp, so stamp_terminated_at/1 genuinely fills that gap — the new comment describes a caller that exists. The regression compares metadata["duration_ms"] against the row's own span, which is the assertion that fails on the old code for the stated reason rather than by luck.
The four smaller items are all answered as comments or ADR text, and stale_after moved with the index regenerated.
Findings below are non-blocking. I did not re-run the three revert proofs; I read the tests and relied on CI for the pass.
Findings
1. conversations.ex:2996 / decisions/0046-durable-turn-deadlines.md:106 — the "owner's lever" is not reachable by an owner, and the ADR says it ships now.
DELETE /api/sandboxes/:id (sandbox_controller.ex:95) is the only owner-facing reset door, and it passes Audited.attribution(conn) and nothing else. No console surface, no admin surface, no force parameter anywhere. The docstring says "It is the owner's lever for a fence nothing else can clear"; ADR 0046 line 106 says "the second needs no scheduler and ships with the journal". Both describe behaviour no owner can reach.
Failure scenario: the incident the ADR describes happens after the stack activates — a provider never acknowledges a stop, the row sits uncertain, the shared persistent home is fenced. The owner calls the documented recovery, gets 409 sandbox_mid_turn, and the lever the ADR promised is reachable only from a remote shell on a prod pod. Bounded, because the ageing sweep is now the real exit — which is why this is a finding and not a block — but the two sentences claim more than the code does, and this repo's rule is that a docstring must not describe unbuilt behaviour as existing. Either wire a force on the delete (seven gates, so probably a PR of its own) or soften those two sentences to say the door is owed and add it to the integration list.
2. conversations.ex:2948 — the agent-delete path destroys a home without retiring its journals.
_unsafe_destroy_home/1 calls _unsafe_retire_home/1 directly, bypassing do_reset_sandbox/2 and therefore ExecutionGuard._unsafe_retire_sandbox/1. Open rows bound to that machine survive its destruction with _unsafe_sandbox_open?/1 still true against a terminated sandbox. It converges once #1746 exists — a ready row is picked by _unsafe_due/1, current_binding?/1 is false against the terminated row, it goes uncertain, then ages out — so nothing is permanently stuck. But reset and agent-delete are siblings and only one of them cleans the journal; worth either the same call or a line saying why the long way round is fine here.
3. execution_guard.ex:383 — the sweep's return value cannot distinguish a row it retired from one it skipped.
The double-check inside with_execution/2 returns {execution, nil, nil} when the row moved between the id scan and the lock, and transaction/1 turns that into {:ok, execution} — the same shape as a retirement. The tests only see [] because the outer query filters, so the race is untested and invisible. #1746's scheduler will want to log how many obligations it wrote off; as written it has to re-read state to find out. _unsafe_recover_submissions/2 has the same shape, so this is consistency rather than regression — a distinguishable skip return would help both.
4. Not addressed, and you did not claim it was: record_sandbox_usage/2 under the advisory lock. The usage_events insert plus the PostHog mirror still runs inside the transaction holding pg_advisory_xact_lock(4316, …), with no line in the docstring saying so. Cheap and non-blocking, as I said last time.
Stack
The note stands and you have it: #1745 (84cb45f3) is still based on feat/durable-turn-deadlines but forked below this head, and #1746 onward chain from it. Nothing here contradicts what the children assume — this commit adds two functions and one option and changes no signature they call — but ADR 0046 now puts the ageing sweep in the deadline supervisor's row, so #1746 owes a scheduler for _unsafe_retire_unresolved/2 as well as the worker. That is the obligation this approval is resting on.
Review of #1746 found the coordinator manufacturing the state #1744's journal could not leave, and running everywhere whether or not anyone asked for it. **It is off unless an operator asked.** The child list defaulted to `true` with only `config/test.exs` turning it off, so every prod and dev node polled `turn_executions` twice a second forever, and nothing in `runtime.exs` read the key — an operator could not stop it without a rebuild. It now defaults to off, `runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way. The tick moved from 1s to 5s and is configurable: a deadline is absolute and durable, so lateness costs precision, not safety. Both variables have rows in `docs/configuration.md`, which `config_reference_test` requires. **A failed termination is written off, not fenced forever.** `call_terminator/2` rescues every failure to `{:error, :termination_unconfirmed}`, and `_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever looked at the row again. One slow `terminate_session` therefore fenced its conversation and its shared home permanently, and took `reset_sandbox/2` with it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour a row in `awaiting_identity` or `uncertain` retires to `stopped` with its `last_error` intact. A retry was the other candidate and is deliberately not what happens. One persisted attempt authorizes exactly one provider write; re-arming a lost attempt would replay an operation whose outcome is unknown, or require assuming `terminate_session/3` is idempotent across a session that may already have been replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim. ADR 0046 records both the sweep and the rejected alternative. **Smaller.** `@providers` listed four backends while `ExecutionTransport` refuses everything but Sprites, so three entries were unreachable and the map implied support that does not exist; it now says Sprites. `Enum.take/2` with a negative count takes from the *end* of a list rather than returning `[]`, which would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying true is not load-bearing. The dependency bump this needs is restored as its own commit below: the re-cut of #1745 dropped it along with the rest of that branch, and `Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0. It stays a separate commit because it moves the ACP stack (acp 0.4.0, runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change. Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…rate a timeout from a dead transport Review notes from #1748, plus one cross-PR semantic conflict the rebase surfaced. **Sprites-only is now stated rather than implied.** `_unsafe_start/5` refuses every other provider and admission rolls back to match, because binding a provider-issued session id from trusted control metadata is a per-adapter capability and only the Sprites adapter has it. The moduledoc and ADR 0046 say so, and a regression asserts that e2b, daytona and runner are refused *without* recording spawn intent — so the turn stays retryable instead of landing in the `awaiting_identity` fence. The important half is that those providers get a refusal at admission and not a silently unbounded turn. **A timeout and a dead transport are different answers.** `call/3` collapsed both into `:transport_unavailable`, which reads as "nothing happened" and is only true of the second: a transport that did not answer in 30 s may be mid-write. Timeouts now return `:transport_timeout`. `write/2` gained an optional timeout so the regression costs 50 ms rather than 30 s. **The `:deadline` timer in `init/1` is documented as a convenience, not the mechanism.** The guarantee is the absolute `deadline_at` on the journal row, which the coordinator acts on whether or not this process survived. **The semantic conflict.** This PR changes completion: every bounded connection now owes remote cleanup, a successful reply included, so a completed turn's row lands in `ready` rather than `completed` and `prior_connection/1` refuses all reuse. That is right — a runtime that answered can still hold background work, which is the shape behind the phantom follow-up turns — but it also means the fence now reaches the happy path, and a #1744 regression asserting the old shape failed here. Updated to drive the row to a confirmed stop, and a second regression covers the corollary: a turn that answered correctly whose cleanup is then lost ages out, leaving its own outcome untouched. ADR 0046 records the change and why the ageing exit is load-bearing rather than a corner case. Full core suite: 6 doctests, 5,022 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1746 found the coordinator manufacturing the state #1744's journal could not leave, and running everywhere whether or not anyone asked for it. **It is off unless an operator asked.** The child list defaulted to `true` with only `config/test.exs` turning it off, so every prod and dev node polled `turn_executions` twice a second forever, and nothing in `runtime.exs` read the key — an operator could not stop it without a rebuild. It now defaults to off, `runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way. The tick moved from 1s to 5s and is configurable: a deadline is absolute and durable, so lateness costs precision, not safety. Both variables have rows in `docs/configuration.md`, which `config_reference_test` requires. **A failed termination is written off, not fenced forever.** `call_terminator/2` rescues every failure to `{:error, :termination_unconfirmed}`, and `_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever looked at the row again. One slow `terminate_session` therefore fenced its conversation and its shared home permanently, and took `reset_sandbox/2` with it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour a row in `awaiting_identity` or `uncertain` retires to `stopped` with its `last_error` intact. A retry was the other candidate and is deliberately not what happens. One persisted attempt authorizes exactly one provider write; re-arming a lost attempt would replay an operation whose outcome is unknown, or require assuming `terminate_session/3` is idempotent across a session that may already have been replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim. ADR 0046 records both the sweep and the rejected alternative. **Smaller.** `@providers` listed four backends while `ExecutionTransport` refuses everything but Sprites, so three entries were unreachable and the map implied support that does not exist; it now says Sprites. `Enum.take/2` with a negative count takes from the *end* of a list rather than returning `[]`, which would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying true is not load-bearing. The dependency bump this needs is restored as its own commit below: the re-cut of #1745 dropped it along with the rest of that branch, and `Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0. It stays a separate commit because it moves the ACP stack (acp 0.4.0, runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change. Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…rate a timeout from a dead transport Review notes from #1748, plus one cross-PR semantic conflict the rebase surfaced. **Sprites-only is now stated rather than implied.** `_unsafe_start/5` refuses every other provider and admission rolls back to match, because binding a provider-issued session id from trusted control metadata is a per-adapter capability and only the Sprites adapter has it. The moduledoc and ADR 0046 say so, and a regression asserts that e2b, daytona and runner are refused *without* recording spawn intent — so the turn stays retryable instead of landing in the `awaiting_identity` fence. The important half is that those providers get a refusal at admission and not a silently unbounded turn. **A timeout and a dead transport are different answers.** `call/3` collapsed both into `:transport_unavailable`, which reads as "nothing happened" and is only true of the second: a transport that did not answer in 30 s may be mid-write. Timeouts now return `:transport_timeout`. `write/2` gained an optional timeout so the regression costs 50 ms rather than 30 s. **The `:deadline` timer in `init/1` is documented as a convenience, not the mechanism.** The guarantee is the absolute `deadline_at` on the journal row, which the coordinator acts on whether or not this process survived. **The semantic conflict.** This PR changes completion: every bounded connection now owes remote cleanup, a successful reply included, so a completed turn's row lands in `ready` rather than `completed` and `prior_connection/1` refuses all reuse. That is right — a runtime that answered can still hold background work, which is the shape behind the phantom follow-up turns — but it also means the fence now reaches the happy path, and a #1744 regression asserting the old shape failed here. Updated to drive the row to a confirmed stop, and a second regression covers the corollary: a turn that answered correctly whose cleanup is then lost ages out, leaving its own outcome untouched. ADR 0046 records the change and why the ageing exit is load-bearing rather than a corner case. Full core suite: 6 doctests, 5,022 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…rate a timeout from a dead transport Review notes from #1748, plus one cross-PR semantic conflict the rebase surfaced. **Sprites-only is now stated rather than implied.** `_unsafe_start/5` refuses every other provider and admission rolls back to match, because binding a provider-issued session id from trusted control metadata is a per-adapter capability and only the Sprites adapter has it. The moduledoc and ADR 0046 say so, and a regression asserts that e2b, daytona and runner are refused *without* recording spawn intent — so the turn stays retryable instead of landing in the `awaiting_identity` fence. The important half is that those providers get a refusal at admission and not a silently unbounded turn. **A timeout and a dead transport are different answers.** `call/3` collapsed both into `:transport_unavailable`, which reads as "nothing happened" and is only true of the second: a transport that did not answer in 30 s may be mid-write. Timeouts now return `:transport_timeout`. `write/2` gained an optional timeout so the regression costs 50 ms rather than 30 s. **The `:deadline` timer in `init/1` is documented as a convenience, not the mechanism.** The guarantee is the absolute `deadline_at` on the journal row, which the coordinator acts on whether or not this process survived. **The semantic conflict.** This PR changes completion: every bounded connection now owes remote cleanup, a successful reply included, so a completed turn's row lands in `ready` rather than `completed` and `prior_connection/1` refuses all reuse. That is right — a runtime that answered can still hold background work, which is the shape behind the phantom follow-up turns — but it also means the fence now reaches the happy path, and a #1744 regression asserting the old shape failed here. Updated to drive the row to a confirmed stop, and a second regression covers the corollary: a turn that answered correctly whose cleanup is then lost ages out, leaving its own outcome untouched. ADR 0046 records the change and why the ageing exit is load-bearing rather than a corner case. Full core suite: 6 doctests, 5,022 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1746 found the coordinator manufacturing the state #1744's journal could not leave, and running everywhere whether or not anyone asked for it. **It is off unless an operator asked.** The child list defaulted to `true` with only `config/test.exs` turning it off, so every prod and dev node polled `turn_executions` twice a second forever, and nothing in `runtime.exs` read the key — an operator could not stop it without a rebuild. It now defaults to off, `runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way. The tick moved from 1s to 5s and is configurable: a deadline is absolute and durable, so lateness costs precision, not safety. Both variables have rows in `docs/configuration.md`, which `config_reference_test` requires. **A failed termination is written off, not fenced forever.** `call_terminator/2` rescues every failure to `{:error, :termination_unconfirmed}`, and `_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever looked at the row again. One slow `terminate_session` therefore fenced its conversation and its shared home permanently, and took `reset_sandbox/2` with it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour a row in `awaiting_identity` or `uncertain` retires to `stopped` with its `last_error` intact. A retry was the other candidate and is deliberately not what happens. One persisted attempt authorizes exactly one provider write; re-arming a lost attempt would replay an operation whose outcome is unknown, or require assuming `terminate_session/3` is idempotent across a session that may already have been replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim. ADR 0046 records both the sweep and the rejected alternative. **Smaller.** `@providers` listed four backends while `ExecutionTransport` refuses everything but Sprites, so three entries were unreachable and the map implied support that does not exist; it now says Sprites. `Enum.take/2` with a negative count takes from the *end* of a list rather than returning `[]`, which would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying true is not load-bearing. The dependency bump this needs is restored as its own commit below: the re-cut of #1745 dropped it along with the rest of that branch, and `Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0. It stays a separate commit because it moves the ACP stack (acp 0.4.0, runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change. Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…rate a timeout from a dead transport Review notes from #1748, plus one cross-PR semantic conflict the rebase surfaced. **Sprites-only is now stated rather than implied.** `_unsafe_start/5` refuses every other provider and admission rolls back to match, because binding a provider-issued session id from trusted control metadata is a per-adapter capability and only the Sprites adapter has it. The moduledoc and ADR 0046 say so, and a regression asserts that e2b, daytona and runner are refused *without* recording spawn intent — so the turn stays retryable instead of landing in the `awaiting_identity` fence. The important half is that those providers get a refusal at admission and not a silently unbounded turn. **A timeout and a dead transport are different answers.** `call/3` collapsed both into `:transport_unavailable`, which reads as "nothing happened" and is only true of the second: a transport that did not answer in 30 s may be mid-write. Timeouts now return `:transport_timeout`. `write/2` gained an optional timeout so the regression costs 50 ms rather than 30 s. **The `:deadline` timer in `init/1` is documented as a convenience, not the mechanism.** The guarantee is the absolute `deadline_at` on the journal row, which the coordinator acts on whether or not this process survived. **The semantic conflict.** This PR changes completion: every bounded connection now owes remote cleanup, a successful reply included, so a completed turn's row lands in `ready` rather than `completed` and `prior_connection/1` refuses all reuse. That is right — a runtime that answered can still hold background work, which is the shape behind the phantom follow-up turns — but it also means the fence now reaches the happy path, and a #1744 regression asserting the old shape failed here. Updated to drive the row to a confirmed stop, and a second regression covers the corollary: a turn that answered correctly whose cleanup is then lost ages out, leaving its own outcome untouched. ADR 0046 records the change and why the ageing exit is load-bearing rather than a corner case. Full core suite: 6 doctests, 5,022 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…rate a timeout from a dead transport Review notes from #1748, plus one cross-PR semantic conflict the rebase surfaced. **Sprites-only is now stated rather than implied.** `_unsafe_start/5` refuses every other provider and admission rolls back to match, because binding a provider-issued session id from trusted control metadata is a per-adapter capability and only the Sprites adapter has it. The moduledoc and ADR 0046 say so, and a regression asserts that e2b, daytona and runner are refused *without* recording spawn intent — so the turn stays retryable instead of landing in the `awaiting_identity` fence. The important half is that those providers get a refusal at admission and not a silently unbounded turn. **A timeout and a dead transport are different answers.** `call/3` collapsed both into `:transport_unavailable`, which reads as "nothing happened" and is only true of the second: a transport that did not answer in 30 s may be mid-write. Timeouts now return `:transport_timeout`. `write/2` gained an optional timeout so the regression costs 50 ms rather than 30 s. **The `:deadline` timer in `init/1` is documented as a convenience, not the mechanism.** The guarantee is the absolute `deadline_at` on the journal row, which the coordinator acts on whether or not this process survived. **The semantic conflict.** This PR changes completion: every bounded connection now owes remote cleanup, a successful reply included, so a completed turn's row lands in `ready` rather than `completed` and `prior_connection/1` refuses all reuse. That is right — a runtime that answered can still hold background work, which is the shape behind the phantom follow-up turns — but it also means the fence now reaches the happy path, and a #1744 regression asserting the old shape failed here. Updated to drive the row to a confirmed stop, and a second regression covers the corollary: a turn that answered correctly whose cleanup is then lost ages out, leaving its own outcome untouched. ADR 0046 records the change and why the ageing exit is load-bearing rather than a corner case. Full core suite: 6 doctests, 5,022 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…rate a timeout from a dead transport Review notes from #1748, plus one cross-PR semantic conflict the rebase surfaced. **Sprites-only is now stated rather than implied.** `_unsafe_start/5` refuses every other provider and admission rolls back to match, because binding a provider-issued session id from trusted control metadata is a per-adapter capability and only the Sprites adapter has it. The moduledoc and ADR 0046 say so, and a regression asserts that e2b, daytona and runner are refused *without* recording spawn intent — so the turn stays retryable instead of landing in the `awaiting_identity` fence. The important half is that those providers get a refusal at admission and not a silently unbounded turn. **A timeout and a dead transport are different answers.** `call/3` collapsed both into `:transport_unavailable`, which reads as "nothing happened" and is only true of the second: a transport that did not answer in 30 s may be mid-write. Timeouts now return `:transport_timeout`. `write/2` gained an optional timeout so the regression costs 50 ms rather than 30 s. **The `:deadline` timer in `init/1` is documented as a convenience, not the mechanism.** The guarantee is the absolute `deadline_at` on the journal row, which the coordinator acts on whether or not this process survived. **The semantic conflict.** This PR changes completion: every bounded connection now owes remote cleanup, a successful reply included, so a completed turn's row lands in `ready` rather than `completed` and `prior_connection/1` refuses all reuse. That is right — a runtime that answered can still hold background work, which is the shape behind the phantom follow-up turns — but it also means the fence now reaches the happy path, and a #1744 regression asserting the old shape failed here. Updated to drive the row to a confirmed stop, and a second regression covers the corollary: a turn that answered correctly whose cleanup is then lost ages out, leaving its own outcome untouched. ADR 0046 records the change and why the ageing exit is load-bearing rather than a corner case. Full core suite: 6 doctests, 5,022 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
…s door Rebase of #1751 onto the re-cut stack, plus its two review notes. **The red CI was staleness and the rebase cleared it.** `hex-audit-gate.exs` was failing on the `decimal` advisory `EEF-CVE-2026-32686`, acknowledged on main in #1753 sixty commits after this branch forked. It exits 0 here. The remaining line is advisory: #1746's dependency bump moved the locked Decimal artifact, so the acknowledgment no longer matches anything and could be removed — that is main's call, not this stack's. **The evidence file no longer reads as if the dead-end were the design.** `turn-parent-fences.json` said unknown spawn recovery "correctly returns failed with awaiting_identity". True as an immediate outcome, and the expectation was right to correct — but stated alone it reads as the final state, which was the thing I could not find a way out of when reviewing #1744. It now says `awaiting_identity` is an obligation with an age and names the sweep that writes it off. **`_unsafe_create_autonomous_turn/1` is dropped.** It admitted background work through `_unsafe_create_turn/1`, which skips the sandbox binding #1764 added, and called the `_unsafe_autonomous_turn/2` helper #1749's re-cut removed for the same reason. Autonomous turns use the one admission path, so `turn_parent_test.exs`'s "closed parents refuse user and background admission" now exercises both doors as the same door. **Two things this branch had that main's admission did not, now in it.** A terminated or failed parent refuses a turn with `:not_running` — `attached?` checks the machine, and a retired actor can still reach the conversation with a queued prompt. And the parent goes `running` inside the admission transaction, so a turn and the status explaining it commit together; a reader could previously see a `running` turn under an `idle` parent for the width of the launch, and a failed launch left the pair disagreeing. `update_all` rather than `update_conversation/2`, because that one audits and an audit insert must not run inside a transaction (ADR 0013). Kept: the generation fencing that is the point of the PR — `_unsafe_write_parent/3`, `_unsafe_recover_turn/2`, `_unsafe_clear_idle_session/2`, and `session_plan/2` returning `{:error, :execution_fenced}` so even the placeholder session write is tied to the admitted turn. Size pin 2739 -> 2733. Full core suite: 6 doctests, 5,080 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review of #1752 found that the release fence took away a release that always worked. The no-server path was unconditional before this branch; it now refused `:busy` whenever a turn row said `running` — and a conversation with no server and a `running` turn is a normal state, not a pathological one. `wake_for_interrupt/1`'s own docstring says so: "the process can have exited (deploy, Horde rebalance, a plain `{:stop, :normal, _}`) while a turn was still marked `running`". Those are exactly the conversations an owner reaches for release on, and refusing there fenced them out of their own recovery with nothing able to un-fence it — the same shape as the permanent reset refusal on #1744. Split into the two things that were conflated: * a `running` turn row refuses only when a **live** server makes it authoritative. `_unsafe_release_parent/3` takes `actor_alive?`, and `release_conversation/2` passes `false` on the branch where `whereis/1` already returned nil. * an unresolved bounded execution refuses either way, as `:execution_fenced` rather than `:busy`. That is a durable fact rather than an inference from a row, and it is bounded: the coordinator writes an obligation off once nothing can resolve it. The distinct name is worth having on its own. `:busy` means a turn a live actor is running and ends by itself; `:execution_fenced` means remote work Fountain cannot yet account for. The test that asserted the old behaviour is rewritten rather than deleted, and says why the opposite was wrong. Two companions: a live actor's running turn still refuses, and the durable fence refuses with and without an actor. Size pin held at 2733 by trimming the two comments this added rather than raising it. Full core suite: 6 doctests, 5,092 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
A late ACP response must not turn an expired review into success, and a lost stop acknowledgment must not authorize another execution. This draft adds the durable journal and database fences needed for those guarantees.
It persists immutable deadlines and remote bindings, arbitrates completion versus expiry, preserves failed/interrupted turns until remote termination is confirmed, prevents reset while execution remains unresolved, and recovers abandoned stop attempts without replay.
Timeout activation remains pending API/account policy, trusted session-identity transport, scheduling and the remaining lifecycle integration. Sprites session metadata awaits superfly/sprites-ex#33. The proposed ADR records the integration checklist and validation limits. This supplies the journal foundation for #1732.
Validation: full
mix precommitpassed (4,632 tests + six doctests; zero failures, two skips, seven exclusions). The standalone PostgreSQL proof ran 20 completion/expiry races and competing termination claims on separate connections, plus confirmed parent/turn lock-delay cases. It made no provider calls. Source hashes and results are indecisions/evidence/turn-deadline-races.json.No production deadline has been activated.
Part of #1732 — held. Superseded on main by #1773–#1793; the residual hunks are being re-cut as focused PRs tracked by #1864, which keeps these frozen until each has a replacement or an explicitly linked deferral. Do not close: they are the reference for that mapping.