feat(sessions): make approval answers and continuation durable - #6530
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
mmabrouk
left a comment
There was a problem hiding this comment.
AGENT-GENERATED review, low weight. Please verify every claim before acting.
Reading guide for #6530 (durable approvals), base feat/session-execution-watchdog, 94 files, one migration (027):
- Core transaction:
commands/service.py respond_interactioncommits the answer, the continuation execution, and the private command together; 202 follows the commit. Lock order is execution then interaction, so Stop and answer serialize on the execution row. - One winner:
session_executionsterminal compare-and-set inexecutions/dao.py settle; the loser reads a setterminal_outcome. The continuation is a fresh execution row, so a Stop guard cannot follow into it. - Delivery:
DirectControlDelivery.deliverforcontinue_interactionuses the shareddeliver(command)->receiptport and hydrates the answer from the interaction resolution; it never touchesstreams/runner_client.py. Post-commit delivery failure marks the execution recoverable and keeps the answer durable. - Runner: admits one continuation per command id (leader), duplicates await the same API-authoritative outcome and never replay. Migration 027 adds a partial unique index on (project_id, source_interaction_id) as a second guard.
- Findings below are two LOW-to-MEDIUM design questions (rollback flag, multi-approval-per-turn) plus small consistency notes. Tests are strong, including the PostgreSQL Stop-vs-answer race and the rollback test. Verdict: ship after confirming the two questions.
ee7a3d5 to
82537be
Compare
82537be to
298911b
Compare
Railway Preview Environment
Updated at 2026-09-05T13:27:59.167Z |
mmabrouk
left a comment
There was a problem hiding this comment.
Agent-generated round 2 review, low weight. Verify before acting.
Head reviewed: 298911b. Base: feat/session-execution-watchdog at a8e7920.
Verdict: SHIP AFTER FIXES. The durable answer path is correct and well covered. Four items should change before live QA: N1, N2, N3, N4.
Round 1 findings
| # | Round 1 | Status at 298911b |
|---|---|---|
| F1 | Multi-approval per turn | RESOLVED. respond_interactions answers a whole turn under one execution lock and returns awaiting_interactions until every gate on the turn is answered. Only then does it settle continued. |
| F2 | No dedicated rollback flag | RESOLVED. durable_approvals added to SessionsConfig, default false, read through the shared env object. |
| F3 | Idempotency-Key handling | RESOLVED. Both respond and cancel return the same 422 through _idempotency_key_too_long_response(). |
| F4 | Recoverable 202 not surfaced | PARTIAL. respondInteractionAnswersAtom computes recoverable, but no caller reads it, and the single-answer atom does not compute it. The card still shows "Answered, waiting" after a failed delivery. |
Orphan sweep merge
Both halves are present. The base newly_lost block is intact: is_running cleared on the row that still names the lost turn, guarded release_alive, conditional force_clear_owner, guarded release_running, mark_turn_superseded, and the running_rows_cleared mirror publish. The sweep still tombstones every swept turn, now in the pre-settlement fence. _map_commands_skipping_unmappable and continue_interaction mappability are unchanged from the base.
A lost continuation is not skipped forever. settle_execution_lost returns False for a continuation, so no terminal record is written, but the fence already ran on that key and the collapse still clears the stream row. The session becomes sendable in the same pass. The continuation execution stays recoverable on purpose so the next Send resumes it.
Three problems came from the reordering. See N3, N5 and N8.
Nits (not inline)
- The same dock state machine now exists three times: the shared
useApprovalDock, the desktopApprovalDock, and the mobileuseApprovalActions. The desktop dock re-implementssettle,answeredanderrorTextinstead of using the hook. One of the three will drift. _reopen_continuation_attemptsetsparent_execution_idto the root continuation id, not the attempt it replaces, so the chain does not record the failed attempt.- Migration 027 downgrade deletes every row with a NULL
terminal_outcome. That is unavoidable for the NOT NULL restore, but say so in the PR body. pruneincontinuation-admission.tsevicts a pending leader after 30 minutes and settles it false. The API compare-and-set still protects, so this is only a confusing log line for a very long turn.- Migration chain is clean: 024 to 026 to 027, one head, additive, nullable, real downgrade. No credential appears in a command row, a delivery payload, or a log line. The command row carries ids plus the hydrated
{approved, tool_call_id}answer only.
Tests run in the inc6 worktree
| Suite | Result |
|---|---|
| API pytest unit, not integration | 3024 passed |
| API pytest integration, on agenta_inc6_r3_core and agenta_inc6_r3_tracing at localhost:5440 | 108 passed, 5 skipped |
| services pytest unit | 162 passed |
| sdks/python pytest unit | 2554 passed, 4 skipped, 14 xfailed |
| services/runner vitest | 2734 passed, 165 files |
| @agenta/chat vitest | 636 passed, 60 files |
| @agenta/entities vitest | 1474 passed, 103 files |
| @agenta/oss vitest | 423 passed, 1 skipped |
| @agenta/mobile vitest | 146 passed, 20 files |
Every skip is unrelated: five Composio live checks with no API key, one pre-existing web skip.
📘 Docs preview
This comment updates in place on every push. |
mmabrouk
left a comment
There was a problem hiding this comment.
Agent-generated round 3 review, low weight. Verify before acting.
Head reviewed: a1435aa13fd69031dfe5076ca3b1ca8035ff12eb. Base: feat/session-execution-watchdog at a8e7920e0f. Round delta: 17 commits, 39 files, +1175 / -247.
Verdict: SHIP AFTER BROWSER QA
Every round-2 finding is resolved in code, and the fixes are the right ones. The two that mattered most, N1 and N3, are correct: the preflight is now additive and fails open in three places, and the collapse is a conditional UPDATE that commits before any Redis release. Nothing here needs another code round. What is missing is a browser pass: the approval path now branches on a server capability that no unit test exercises against a real API, and N2's steer case was never seen in a browser.
Resolution table
| # | Round 2 finding | Status | Evidence at a1435aa |
|---|---|---|---|
| N1 | Preflight is an unflagged hard dependency on every send | RESOLVED | Three independent fail-open layers. resumeSessionContinuation returns false on a transport error or a bad shape (session/api/api.ts:1048, :1062). assertNoResumedSessionContinuation catches and returns (continuationPreflight.ts:11). The atom short-circuits when the server does not advertise the capability (interactionAnswer.ts:45). An old API returns no capabilities, the zod default is false, and no resume call is made. |
| N2 | A parked continuation blocks every new message | RESOLVED | fetch_resumable_continuation now matches SessionExecutionDBE.state == "recoverable" only (commands/dao.py:281). A parked applied/started continuation whose execution is running no longer owns the next send, so it steers exactly like a first-turn park. The state rule is written down in contracts/commands.md. |
| N3 | The pre-commit fence lets the collapse clobber a live turn's row | RESOLVED | The fence no longer releases alive or running; it captures the owner and tombstones only (orphan_sweep.py:563). The collapse is a conditional UPDATE on id, project, session, turn_id and the observed updated_at, and a row that advanced is skipped (orphan_sweep.py:729 to :766). Redis release runs after session.commit() (:778). synchronize_session=False keeps the ORM from re-syncing the guarded write. Pinned by test_stream_that_advances_during_sweep_is_not_collapsed and test_redis_release_happens_only_after_stream_collapse_commits. |
| N4 | A Redis error cancels the caller and kills the watchdog | RESOLVED | owner_task.cancel() is gone. A renewal error logs and retries until the lease age reaches lease_seconds, then sets lease.lost (locks.py:101 to :118). The owner checks with guard.ensure_held(). The sweep catches the resulting SessionHeartbeatGuardLost per session. |
| N5 | A guard timeout aborts the whole pass and logs the wrong thing | RESOLVED | Each session's fence is wrapped, and TimeoutError, SessionHeartbeatGuardLost and any other exception skip only that session (orphan_sweep.py:571 to :597). Skipped sessions are dropped from orphans and unsettled before settlement. |
| N6 | Flag off is not the old path | RESOLVED | The capability signal is capabilities.durable_approvals on GET /sessions/{id}/stream, filled from env.agenta.sessions.durable_approvals (router.py:447, models.py:137). submitApprovalForCapability routes on it: durable takes the server-owned dispatcher, off takes recordAnswerThenRelease with addToolApprovalResponse, which is the pre-lane path (serverOwnedApproval.ts:31). A server with no capabilities field parses to false through the zod default, so an old API takes the legacy path. |
| N7 | The sweep's continuation reconciliation reads the Stop flag | RESOLVED | orphan_sweep.py:516 now reads durable_approvals, matching the record-ingest hook. |
| N8 | The fence is not flag-gated, and a swept session keeps a dead affinity | RESOLVED | The fence is inside if env.agenta.sessions.durable_approvals (orphan_sweep.py:557), so the flag-off sweep keeps the base's broad force_cancel_alive / clear_running / force_clear_owner cleanup (:790 to :812). With the flag on the fence captures get_owner, not get_alive_owner, so an expired alive key no longer hides the affinity, and clear_owner releases it conditionally after the commit. |
| N9 | A partial answer cannot be retried after a sibling completes the turn | RESOLVED | A terminal source with every requested interaction already responded at the same resolution returns an admission instead of a 409 (commands/service.py:472 to :492). |
| N10 | A flag-off batch with a missing anchor is a 500 | RESOLVED | The anchor check moved above the capability branch, so both paths return 422 (router.py:1102). |
| N11 | Mobile Approve all can silently do nothing | RESOLVED | selectApprovalTargets throws on a batch spanning more than one turn_id (approvalTargets.ts:36), and the throw is inside the outer try in useApprovalActions, so it sets the error phase. The mobile dock does pass errorText to the card (ApprovalDock.tsx:52); the round-2 note on that half was wrong. |
| N12 | An idempotent replay returns a stale execution id | RESOLVED | existing.target_turn_id or data["continuation_execution_id"] (commands/service.py:458). |
| N13 | Detached start became strict for every caller | RESOLVED | strict_first_record defaults false and is set only when meta["control_command_id"] is present (workflows/service.py:755, :3015). The non-strict path matches the base byte for byte. |
| F4 | Recoverable 202 not surfaced on the card | RESOLVED | recoverable flows from execution.state === "recoverable" through the atom, the shared dock, the desktop dock and the mobile phase, into two shared ApprovalCard strings (ApprovalCard.tsx:207, :402). Surfacing was the right call: the answer is durably saved and the user's next Send is the retry. |
New findings
Four, all low. None blocks a merge.
- D1 (LOW-MEDIUM): the guarded collapse can spare a row whose turn the same pass already tombstoned and settled lost. Inline.
- D2 (LOW): the generated Fern client was hand-patched with an anonymous inline type. Inline.
- D3 (LOW): the capability GET runs uncached on every Send and every approval click. Inline.
- D4 (LOW): a lost guard lease turns a heartbeat that already did its work into a 500. Inline.
Base interaction
#6501 has advanced to 15d58add1c, which adds _reclaim_affinity_from_a_departed_replica immediately above heartbeat() in streams/service.py. This lane edits heartbeat() at the same place, so expect a textual conflict there and nothing more. Semantically the two agree: the reclaim runs inside _heartbeat_locked, which is inside this lane's guard, and guard.ensure_held() must stay after it. The base's own KNOWN LIMIT, a parked approval turn losing affinity to a second replica and being tombstoned, is softened by this lane rather than worsened: a durable answer to a tombstoned parked turn still lands as a new continuation execution.
Tests
Run in /home/mahmoud/code/agenta-2-worktrees/inc6. Postgres-gated suites used agenta_inc6_r3_core and agenta_inc6_r3_tracing on port 5440. agenta_ee_core was never touched.
| Suite | Result |
|---|---|
API oss/tests/pytest/unit/sessions, no Postgres |
610 passed, 80 skipped |
API oss/tests/pytest/unit/sessions, dedicated databases |
690 passed, 0 skipped |
services/runner test:unit |
2708 passed, 163 files |
@agenta/chat |
640 passed, 60 files |
@agenta/chat types:check |
clean |
@agenta/entities test:unit |
1478 passed, 103 files |
@agenta/entities test:integration |
31 skipped, no service |
@agenta/mobile |
147 passed, 20 files |
Browser scenarios that must pass
- Desktop, flag on: approve one gate, watch the continuation resume, and confirm the card leaves "Answered, waiting for the agent".
- Desktop, flag off: approve one gate and confirm the SDK resume goes out and the local gate releases, with no call to the durable respond route.
- Desktop, flag on, parallel approvals: answer two gates on one turn in one batch, and separately answer gate A while gate B is still pending, then answer B.
- Desktop, flag on: let a continuation park on a second approval, then type a message instead of answering it. The message must be accepted. This is N2 and it has never been seen in a browser.
- Reload the page while an approval is pending, then answer from the reloaded tab.
- Answer a gate, restart the runner before it delivers, then send a message and confirm the saved answer is redelivered and the card showed "Answer saved, retry needed".
- Mobile, flag on and flag off: single approve, Approve all on one execution, and Approve all with gates from two executions, which must show an error and not post.
- Old API check: point the web build at a server without the
capabilitiesfield and confirm approvals still work through the legacy path.
Read-only review. No file, git or docker change was made.
a1435aa to
93ad3b9
Compare
mmabrouk
left a comment
There was a problem hiding this comment.
Review summary
Reviewed commit 93ad3b92c31463ee5ae04bae6a912e8fd98ad98f.
- Risky:
api/oss/src/core/sessions/commands/service.py:647- an ambiguous delivery result can demote an already-running continuation torecoverable, allowing a second continuation to start. - Risky:
api/oss/src/core/sessions/records/service.py:152andapi/oss/src/apis/fastapi/sessions/router.py:806- a cancelled continuation is settled ascompletedif its terminal record arrives before the Stop outcome. - Risky:
api/oss/src/apis/fastapi/sessions/models.py:414- a valid idempotent replay can return the domain stateterminal, which the response model rejects and converts into HTTP 500. - Missing tests: an admission-then-ambiguous-delivery race, cancelled-record-before-Stop settlement, and an endpoint-level replay after the source execution becomes terminal.
I recommend addressing these findings before merge. The stacked base has also advanced, and GitHub currently reports this PR as conflicting.
|
Agent-generated, low weight. Reviewed and landed a fix for a durable continuation that never reached the service. Head is now Symptom. A user approves a tool call, Cause. A durable continuation is a server-side invoke, and an invoke finds its service URL only through the request's Fix.
Why the rebuilt map is safe. The turn and stream rows are written by the runner's Tests.
Five new API tests cover the turn fallback, the stream fallback, the gate row winning over both, a session with no identity anywhere, and the helper's family filter. Four new web tests pin the return value, the steer ordering, the approve-side suppression, and the blank-note case. Follow-ups, not fixed here. Both are producer defects that live on
One more, unrelated and untouched: the inline last-resort composition in |
|
Agent-generated, low weight. Two commits on top of SymptomAfter the references fix landed, the browser pass still saw approvals that did not continue. Every durable continuation delivery in that window logged The database says the deliveries mostly WORKED. Commands CauseThe API parsed the runner's wire vocabulary off the service's stream.
The second signature is the same layering from the other side. The runner's refusal record is The follow-on damage was in the sessions service: Fix
No runner change. The runner emits the right records. Retry exhaustion
Tests1229 passed, 80 skipped across New:
Both new sessions tests were confirmed red with the fixes disabled. Still open, not fixed hereThe "closed the stream" signature has one remaining producer, and it is a different defect. In sessions |
|
Agent-generated, low weight. A third cause behind the same browser pass, now fixed in |
|
Agent-generated, low weight. A browser Stop was deleting the warm sandbox. Pushed as d69ad52. SymptomA Pi agent on Daytona with an OpenRouter model. The user presses Stop, the turn stops correctly, and the next message comes back cold: new sandbox, replayed transcript, no native harness session. Deterministic on the increment-6 stack: three Stops, three evictions, zero warm parks. Cause
The runner log shows every ingredient of a warm park present and the sandbox deleted anyway: FixDecide the settled user Stop first, in One existing assertion changed on purpose. TestsA dispatch-level replay in
Follow-up for the reviewerThe runner predicate is identical on Two smaller things worth filing. The eviction log cannot tell the two refusals apart, since |
755ef53 to
4aecd1f
Compare
Symptom: A message held during an approval remained as '1 queued message' after the continuation finished and never sent. Cause: Record replay preserved the approval-responded part but discarded the terminal done marker, so queue release kept classifying the settled transcript as a pre-resume window. Fix: Preserve non-paused terminal records in message metadata and let a ready queue release once that durable terminal marker is present and no actionable gate remains. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Track durable continuation executions separately from their paused source turns, and release held messages only after the continuation terminal record. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Treat the first record from a continuation execution as proof that its source approval was answered, while preserving interaction-response settlement when present. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
A message typed while an approval card was open was released into the running continuation about sixteen seconds after the answer. The runner resolved the second turn for the session by superseding: it destroyed the warm sandbox mid-call, the approved "sleep 25 && echo ..." returned "Command aborted", and the released message's own turn was declared lost. The user lost both. The hold added in round 8 was correct and was bypassed one level up. `useAgentChatQueue` ORs `canReleaseQueuedMessage` with the orphan escape hatch, and a durable answer makes that hatch true every time: the answer nulls the live gate marker via `retireDurable`, and the first adopted server transcript puts every message id in `restoredIdsRef`, so the paused tail reads as a restored "resume imminent" turn that nothing can fire. The sixteen seconds were not the hold working, they were the wait for the next record-log read. A second hole sat behind it. `approvalContinuation` is stamped from the continuation's FIRST record, which lands eight to eleven seconds after the answer, and a transcript adopted inside that window shows a paused turn whose gate is answered, which every predicate reads as settled. Put one hold in front of every release path, and give it a signal that exists from the moment of the answer: the respond body's `execution.id`, carried through `ApprovalSubmissionOutcome` to the queue, released only on that execution's own terminal record. A user stop still outranks it, and `CONTINUATION_HOLD_MAX_MS` bounds it so a continuation that is never delivered cannot strand the queue with no dock to unblock it. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Every chat send runs a preflight against
POST /sessions/{id}/continuations/resume, which aborts the send when a
durable continuation owns the session. It answered "nobody owns this"
while a continuation was executing, so the browser invoked the runner
directly and the approved tool call was destroyed with the sandbox.
The refusal was already written.
`resume_recoverable_continuation` has an explicit `state == running`
branch that returns True without redelivering the command. It was
unreachable: `fetch_resumable_continuation` admitted only
`pending_delivery` and `recoverable`, so a delivered-and-running
continuation matched no branch and the DAO returned None.
Let `running` through the filter, and split the two live shapes it covers
in the service, where the discriminator belongs. A continuation PARKED on
its own approval has a pending interaction row against its execution;
nothing is in flight to destroy, so a Send is a steer and stays allowed,
which is review finding N2. A continuation EXECUTING inside a tool call
has no such row, and its Send is refused. The signal is the interaction
row rather than the Redis `running` lock because the lock is absent both
when a turn parks and when its runner goes quiet mid-call, and those two
must not answer the same way.
Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Both fixtures are the real durable record logs from the increment-6 stack, ordered exactly as GET /sessions/records returns them, so the regression is pinned against what the server actually wrote rather than against a hand-built shape. The queue test walks session 9d40cfcc prefix by prefix and asserts the held message survives the continuation's re-raised tool call and its interaction response, which is where the round-8 build sent, and releases only on the continuation's own terminal record. It also covers the window before the first continuation record, the bounded ceiling, and an answer that started no continuation. The dock test replays session 973bfdbd and pins the retirement contract. Round 8 reported the dock as still open reading "Answered, waiting for the agent", but screenshots 41 and 47 show no dock at either round: a closed HeightCollapse keeps its latched card mounted at zero height with aria-hidden and inert, and ApprovalDock only resets `answered` when the current approval id changes, so a DOM read finds the retired text forever. Pinning `getPendingApprovals`, which is what `open` is built from, gives the next round the state that drives the pixels. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
The round-7 status note was committed into the worktree by mistake. It is a working artifact, not source, and it has been moved to the night folder. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Publish committed interaction projections on the session watch relay and reconcile them immediately in every reader, with a one-second gate-only query fallback. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
9e878ea to
9fdf78b
Compare
Website previewPreview URL: https://pr-6530-agenta-website-preview.mahmoud-637.workers.dev Built from |
Mark a queued send as locally owned before transport dispatch so liveness cannot misclassify its run as foreign. Document executing and parked continuation admission. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Keep the answering tab locally running for the continuation execution returned by the durable approval response, without claiming ownership in observer tabs. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Exclude cancelled terminal records from completion reconciliation so the runner's Stop outcome remains the terminal compare-and-set winner. Cover both the synchronous ingest guard and persisted-record-first ordering. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Allow a valid matching retry to return the terminal source execution after a sibling answer has already continued the turn. Cover the service branch and FastAPI response construction. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Derive the running-elsewhere strip from the shared local run status so a detached continuation is not labeled remote in its owner tab. Cover owner, observer, and locally parked states. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Ruff 0.15.12 collapses the comprehension guard onto one line. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
9fdf78b to
c5659f2
Compare
Increment 6 registered the session execution as soon as the abort controller existed. On the milestone 2 base that reintroduces the bug d91ed34 fixed: a contender the coordination plane refuses replaces the admitted turn's Stop handle, so a Stop for the live turn reaches the refused one and the live turn keeps running. The registration below, after admission, is the only one. A durable continuation reaches it on the same path, and the window this removes is one heartbeat round trip, not the environment acquisition the original comment described. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Milestone 2 added a terminal branch for a user Stop that returns before the continuation bookkeeping below it. A continuation the runner cancels then keeps the state "running" forever, so the durable-continuation hold never releases the queued message. Settle the continuation in that branch as well. Nothing else in the Stop branch changes, so a stopped turn still renders Stopped and still carries no recordTerminal marker. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
The execution-guard suite predates increment 6. Its module mocks list the atoms and asset helpers the hook imported at the time, so the added durable approval atoms and the continuation preflight came back undefined and the hook threw on mount. The preflight mock is a pass-through: this suite drives the execution guard, not the durable retry. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
Prettier's CI job checks the package tests directories that lint-fix does not reach. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk
mmabrouk
left a comment
There was a problem hiding this comment.
Agent-generated Codex review, low weight.
SHIP
Re-checked only the rebase adaptation (15d58add1c..9e878ea40b → c8721c29f0..6954262224). The adapted/added commits preserve the durable answer transaction and continuation dispatch, control-only strict detached-start parsing, executing-versus-parked Send rule, carry-and-park plus Stop-before-disconnect ordering, observer push, and client hold/ownership. M1/M2 Stop cleanup, admission, refused-send recovery, live relay, durable reconnect, and shared sender remain intact. The two omitted commits reverse-apply at head, migration 027 follows base 026, and the lane file list exactly matches the live PR’s 154 files.
Verification: git diff --check; focused continuation-admission and dispatcher pytest files — 45 passed.
Context
An approval answer today is accepted by the API and continued by the runner, but nothing durable ties the answer, the continuation execution, and its delivery together: a lost response or a failed delivery after acceptance can drop an accepted answer. This is increment 6 of the session-control design on PR #6495, stacked on the watchdog and settlement work in PR #6501.
Changes
AGENTA_SESSIONS_DURABLE_STOP; off keeps the current response endpoint path.Tests
AGENTA_SESSIONS_DURABLE_APPROVALS, the same Idempotency-Key length rule on respond and cancel, and the card shows a recoverable 202 as answered and waiting. The live proof follows the redeploy.Agent-generated, low weight. Not merged.
https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV
Browser pass of 2026-09-04, evening, on a dedicated stack (head 93ad3b9 then ef9c028)
Fresh account, Pi harness, local sandbox, approvals flag on. Evidence:
~/agenta-qa-evidence/2026-09-04-inc6-browser/.38a2b466a6,ef9c028337): a continuation could not find the runner because it built its invoke request from the gate row's references only; a session whose first message carried none never continued. The dispatcher now resolves references from the gate row, then the session's turns, then the stream row. The "recoverable" state now reaches the card.