Skip to content

feat(sessions): make approval answers and continuation durable - #6530

Merged
mmabrouk merged 58 commits into
feat/session-approvals-queuefrom
feat/session-durable-approvals
Sep 5, 2026
Merged

feat(sessions): make approval answers and continuation durable#6530
mmabrouk merged 58 commits into
feat/session-approvals-queuefrom
feat/session-durable-approvals

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member

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

  • The API commits the interaction response, the continuation execution, and the continuation command in one core transaction and returns 202; if that transaction fails the interaction stays pending with the error envelope.
  • The continuation is delivered through the same command port as Stop, with the same redelivery and settlement rules; a delivery failure after commit leaves the answer durable and the execution recoverable, and the next delivery or the next Send reopens it. Recovery is fenced so a competing continuation cannot start twice.
  • Stop and an answer that arrive together have one committed winner through the terminal compare-and-set; the guard never follows from the old execution into the continuation; a duplicate answer returns the same ids and a conflicting one gets 409.
  • Runner: a durable continuation is admitted once per command id; duplicate deliveries are acknowledged without replaying; ambiguous callback failures stay retryable.
  • Client: the approval card shows "sending" until 202, then "answered" while the continuation starts; a failed request keeps the card pending with the error; the desktop and mobile Send paths first ask the API to redeliver a recoverable continuation before they start a competing request.
  • Behind the existing switch AGENTA_SESSIONS_DURABLE_STOP; off keeps the current response endpoint path.

Tests

  • API sessions suite on Postgres: 655 passed after the rebase onto the rebuilt fix(sessions): settle executions after runner or sandbox loss #6501; focused recovery and DAO tests 41; runner suite 2,706; chat 635, entities 1,473, mobile 146; type checks and lint clean.
  • Whole-PR review by an Opus agent: ship after fixes, ten inline comments on the PR. Verified: one transaction with 202 after commit, one winner through the terminal compare-and-set with a real race test, delivery through the shared port, a recovery fence, idempotent retry, the runner admits once per command id, the flag-off path unchanged, migration 027 additive with a downgrade. Round 2 in progress: a turn with several pending approvals keeps its execution open until all are answered (the first answer no longer closes the rest), a dedicated switch 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/.

  • The durable answer holds: the respond call stores the answer in one transaction; seven of nine approvals continued in 4 to 12 seconds; a reload during a pending approval keeps the card and its buttons, and approving after the reload continues the turn; the mobile approve path and mobile as the second device work.
  • Defect fixed in this PR (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.
  • Open, for the next round: the approval card in a tab that did not answer never updates (a second Approve is safe and catches up); a message typed while a card is parked goes to a hidden queue and only releases when the turn resumes; a message sent during a parked gate fails after the approval with "A saved approval is resuming" instead of superseding the gate; the dock stays on "Answered, waiting" after the turn ends; a replayed resolved gate shows "Needs your approval" after a reload.
  • Not reproducible as written: two cards at once; the harness defers the second call, and the sequential path (approve one, deny the next) passes.
  • Producer defects on main, outside this PR: a first message right after creating an agent carries no workflow references; the default agent's effective config exceeds the 64 KB stamp cap.

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 5, 2026 12:11pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • release/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Team

Run ID: e711fdfd-9a3e-4109-90db-1452b1cb4d8a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. Core transaction: commands/service.py respond_interaction commits 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.
  2. One winner: session_executions terminal compare-and-set in executions/dao.py settle; the loser reads a set terminal_outcome. The continuation is a fresh execution row, so a Stop guard cannot follow into it.
  3. Delivery: DirectControlDelivery.deliver for continue_interaction uses the shared deliver(command)->receipt port and hydrates the answer from the interaction resolution; it never touches streams/runner_client.py. Post-commit delivery failure marks the execution recoverable and keeps the answer durable.
  4. 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.
  5. 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.

Comment thread api/oss/src/core/sessions/commands/service.py Outdated
Comment thread api/oss/src/core/sessions/commands/service.py
Comment thread api/oss/src/dbs/postgres/sessions/executions/dao.py
Comment thread api/oss/src/core/sessions/commands/service.py Outdated
Comment thread api/oss/src/core/sessions/commands/service.py
Comment thread api/oss/src/dbs/http/sessions/control_delivery_direct.py
Comment thread services/runner/src/server.ts
Comment thread api/oss/src/apis/fastapi/sessions/router.py Outdated
Comment thread api/oss/src/apis/fastapi/sessions/router.py
Comment thread web/packages/agenta-chat/src/hooks/useApprovalDock.ts Outdated
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-09-05T13:27:59.167Z

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 desktop ApprovalDock, and the mobile useApprovalActions. The desktop dock re-implements settle, answered and errorText instead of using the hook. One of the three will drift.
  • _reopen_continuation_attempt sets parent_execution_id to 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.
  • prune in continuation-admission.ts evicts 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.

Comment thread web/packages/agenta-entities/src/session/api/api.ts Outdated
Comment thread api/oss/src/core/sessions/commands/service.py
Comment thread api/oss/src/tasks/asyncio/sessions/orphan_sweep.py Outdated
Comment thread api/oss/src/dbs/redis/sessions/locks.py Outdated
Comment thread api/oss/src/dbs/redis/sessions/locks.py
Comment thread api/oss/src/apis/fastapi/sessions/router.py
Comment thread web/mobile/src/features/chat/useApprovalActions.ts
Comment thread api/oss/src/core/sessions/commands/service.py Outdated
Comment thread api/oss/src/core/workflows/service.py Outdated
Comment thread web/packages/agenta-entities/src/session/state/interactionAnswer.ts
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📘 Docs preview

Status ✅ Ready
Preview https://pr-6530-agenta-docs-preview.mahmoud-637.workers.dev/docs
Inspect Actions run
Commit 69542622241cf31c06219f694b4b9991b3ea8ea5

This comment updates in place on every push.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Desktop, flag on: approve one gate, watch the continuation resume, and confirm the card leaves "Answered, waiting for the agent".
  2. 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.
  3. 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.
  4. 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.
  5. Reload the page while an approval is pending, then answer from the reloaded tab.
  6. 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".
  7. 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.
  8. Old API check: point the web build at a server without the capabilities field and confirm approvals still work through the legacy path.

Read-only review. No file, git or docker change was made.

Comment thread api/oss/src/tasks/asyncio/sessions/orphan_sweep.py
Comment thread web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts Outdated
Comment thread web/packages/agenta-entities/src/session/state/interactionAnswer.ts
Comment thread api/oss/src/core/sessions/streams/service.py Outdated
@mmabrouk
mmabrouk force-pushed the feat/session-durable-approvals branch from a1435aa to 93ad3b9 Compare September 4, 2026 15:48
@mmabrouk mmabrouk added lgtm This PR has been approved by a maintainer 1 points Created by Linear-GitHub Sync labels Sep 4, 2026

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to recoverable, allowing a second continuation to start.
  • Risky: api/oss/src/core/sessions/records/service.py:152 and api/oss/src/apis/fastapi/sessions/router.py:806 - a cancelled continuation is settled as completed if 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 state terminal, 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.

Comment thread api/oss/src/core/sessions/commands/service.py Outdated
Comment thread api/oss/src/core/sessions/records/service.py
Comment thread api/oss/src/apis/fastapi/sessions/models.py
@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight.

Reviewed and landed a fix for a durable continuation that never reached the service. Head is now ef9c028337.

Symptom. A user approves a tool call, POST /api/sessions/interactions/<id>/respond returns 202, and the turn never continues. The API log repeats control delivery unreachable ... Workflow revision has no runnable service URL. every two minutes, three times, then the command settles obsolete / lost. The card stays on "Answered, waiting for the agent" the whole time.

Cause. A durable continuation is a server-side invoke, and an invoke finds its service URL only through the request's references. WorkflowsService._ensure_request_revision returns at once when a request carries neither data.revision nor references, so _get_service_url gets no revision and returns None. InteractionsDispatcher.respond_many took the references from the gate row alone (interaction.data.references). A session whose first Send carried no references stamps none on the gate row, so the invoke had no URL and every redelivery failed. This is an edge, not the common case: on the same stack every other session carried workflow and workflow_variant on its gate rows, and all five of their continuations settled applied or started.

Fix.

  • api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py_session_references resolves the identity from the gate row first, then from the session's own session_turns.references, then from session_streams.references. The new keyed_references helper folds the stored flat list (family in each element's key) back into the keyed map an invoke carries, and drops any family _validate_execution_reference_families would reject. Both reads are read-only and best effort: a failed read logs and falls through, because the continuation is already durable.
  • api/entrypoints/routers.py — passes the existing turns and streams services into the dispatcher.
  • web/oss/src/components/AgentChatSlice/AgentConversation.tsx plus the new assets/answerThenSteer.tshandleApprovalResponse returned answerApproval(...).then(() => { ... }), whose arrow body returned nothing, so the dock's result.value?.recoverable was always false and the card could never show "Answer saved, retry needed". The ordered click is now the extracted helper, which answers the gate, sends a denial's steer note after the answer exactly as before, and returns the submission outcome. No behaviour change beyond the return value.

Why the rebuilt map is safe. The turn and stream rows are written by the runner's buildWorkflowReferenceList, which is buildWorkflowReferences plus a key on each element. Dropping key gives back the same {id, slug, version} wire Reference the gate row carries, so the fallback and the winning path are the same shape. The query is scoped by project_id and session_id, and query_turns orders turn_index descending, so the most recent recorded identity wins, never an older one. The gate row still wins when it has references, and a session with no identity anywhere still sends the reference-less request it sent before.

Tests.

suite result
api/oss/tests/pytest/unit/sessions 703 passed
uvx ruff@0.15.12 format --check and check on api clean
oss web: answerThenSteer, ApprovalDock, ApprovalDock.wiring 14 passed
prettier and eslint on the touched web files clean

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 main, not in this PR:

  1. The default agent's config never gets stamped. services-1 logs agent: effective config is 147193 B, over the 65536 B stamp cap; not stamped. MAX_STAMPED_BYTES (sdks/python/agenta/sdk/agents/utils/effective_config.py:39) was sized at 3x a dev corpus whose max was 20 KB, and the default agent is 7x that, almost all tool JSON-Schema. So data.parameters is absent on essentially every gate row for that agent, and every durable resume degrades to reference hydration against the variant's HEAD revision.
  2. A first Send right after creating an agent can go out anonymous. buildAgentReferences returns null when the revision entity is not in the store yet (web/packages/agenta-playground/src/state/execution/agentRequest.ts:89), so the invoke carries no references and nothing downstream records a workflow identity. That is why the failing session had none.

One more, unrelated and untouched: the inline last-resort composition in api/oss/src/apis/fastapi/sessions/router.py:1268 still builds references from interaction.data.references alone, so it keeps the original defect on the path used by minimal and test compositions.

https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk

@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight.

Two commits on top of ef9c028337: 7522fd23af and 2c19d90ade.

Symptom

After the references fix landed, the browser pass still saw approvals that did not continue. Every durable continuation delivery in that window logged control delivery unreachable, either "Workflow service emitted an unknown record before detached start" or "Workflow service closed the stream before emitting a started record". Eight commands, and the API log holds exactly eight respond calls.

The database says the deliveries mostly WORKED. Commands 01a06d76 and 01a06d7d are applied / started with one delivery attempt each, and their executions reached terminal / completed. They still carry error.code = continuation_delivery_failed, so the card offered a retry for work that had already finished.

Cause

The API parsed the runner's wire vocabulary off the service's stream.

_stream_service_started required the first NDJSON record to carry kind: "event" or a successful kind: "result". That is the runner's StreamRecord shape. The API does not call the runner. It calls the deployed workflow service, which re-frames every runner record as an agenta event, {"type", "data"}, in agent_event_stream and _ndjson_stream. There is no kind field anywhere on that wire, so the strict branch rejected the first record of every continuation, always.

The second signature is the same layering from the other side. The runner's refusal record is {"kind": "result", ok: false}, but the SDK turns it into an exception inside an ASGI response whose 200 is already committed, so the service closes the stream having written nothing.

The follow-on damage was in the sessions service: _mark_continuation_recoverable called set_state with no expected_states, so a spurious unreachable receipt demoted a running execution back to recoverable.

Fix

  • _stream_service_started accepts the first JSON object as the started handshake. A new _detached_start_failure rejects only an explicit failure frame and reads one in either vocabulary: the service's {"type": "error"} and the runner's {"kind": "result", "result": {"ok": false}} where a deployment forwards runner records verbatim.
  • _mark_continuation_recoverable passes expected_states, so a failed transport can never demote a running execution, and the caller reports recoverable only when the write applied.
  • _deliver answers with an exhausted receipt when the delivery budget is spent, so the message names the retry the user actually owns.
  • resume_recoverable_continuation settles an exhausted command through the existing exhaustion path before the reopen, so a Send arriving before the sweep retargets a fresh execution.

No runner change. The runner emits the right records.

Retry exhaustion

max_deliveries is 3. Each _deliver reserves one attempt; record_delivery_attempt refuses once the count is spent. The sweep redelivers a pending continuation while the budget lasts, then settles it obsolete / lost and turns the execution recoverable with continuation_delivery_exhausted. The user's next Send reopens it against a fresh execution id with the count reset. Command 01a06d7a ended exactly there, after attempts at 17:32:29Z, 17:34:06Z and 17:36:06Z and a settle at 17:38:06Z. The roughly two-minute spacing comes from the 90-second admission timeout in expire_claims, not from the 10-second sweep.

Tests

1229 passed, 80 skipped across oss/tests/pytest/unit/sessions and oss/tests/pytest/unit/workflows. With Postgres, the sessions suite is 705 passed. uvx ruff@0.15.12 format and check clean.

New:

  • test_stream_service_started_accepts_a_service_event_frame_as_the_start, five cases replaying the real first frames.
  • test_stream_service_started_reports_a_runner_refusal_verbatim and test_stream_service_started_reports_an_empty_stream_as_a_failed_start.
  • test_a_late_delivery_failure_does_not_demote_a_running_continuation.
  • test_a_send_after_the_budget_is_spent_reopens_the_continuation.

Both new sessions tests were confirmed red with the fixes disabled.

Still open, not fixed here

The "closed the stream" signature has one remaining producer, and it is a different defect. In sessions d66e2920 and 6d06f624 the GATED turn never parked and never ended: the runner logs pendingApproval and then no park-approval, no tool_result, no done. The source turn kept its watchdog beating running=true, so every continuation aimed at the new turn came back INTERRUPTED. The repeated multi-line command on one of them is not the cause; the other had a plain single-line echo.

https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk

@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight. A third cause behind the same browser pass, now fixed in 5700408966. A gated turn could raise its approval card and then stop: the runner logged outcome=pendingApproval and after that nothing for that turn, no park-approval, no tool_result, no done, while its alive watchdog kept beating running=true on the source turn, so every continuation aimed at the next turn was refused for want of ownership. That is the one remaining producer of the "closed the stream before emitting a started record" signature, and it hit three sessions of the pass. The trigger is a Pi PARALLEL tool batch: the model asks for a Read and a Bash together, the Read answers allow and the Bash parks, and the allowed Read then never closes because Pi executes no call in a batch while a sibling gate is open. The carry-and-park branch that exists for exactly this case was guarded by opts.resume, so a first turn skipped it and took the closure wait, bounded by the 30-minute per-tool-call budget. Dropping opts.resume is the whole fix; a new case in session-keepalive-approval.test.ts drives the real runTurn with no resume and asserts zero closure waits are armed, and it fails with the guard restored. Two things the evidence rules out: the args shape (one failure had a repeated multi-line command, another a plain single-line echo) and the runner restart (the first gated turn after a restart parked normally, and so did a turn three minutes after the restart that broke two others, on unpatched code). The predicate is pre-existing main code rather than this stack's, so it also needs to reach feat/session-control, where the warm-park lane owns the paused-turn teardown. One design question is deliberately left open for @mmabrouk: the periodic heartbeat claims running=true until the turn returns, so any slow teardown looks like a live turn to a continuation; moving that claim to the pause would close the class, and it is not in this commit.

https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk

@mmabrouk

mmabrouk commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Agent-generated, low weight.

A browser Stop was deleting the warm sandbox. Pushed as d69ad52.

Symptom

A 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

shouldPark read the client-disconnect flag on its first line, before the user-Stop test that may park. The Stop button aborts its own chat stream in the same tick it sends the durable cancel command (useAgentChatSession.ts, handleStop), so the disconnect and the labelled abort always arrive together and every real Stop took the destroy branch.

The runner log shows every ingredient of a warm park present and the sandbox deleted anyway:

[control] aborted command=01a06de7-... session=832b2aee-... turn=afd4bfb9-...
[sandbox-agent] stage=harness_cancel sent=true settled=true elapsed_ms=128
[sandbox-agent] prompt stopReason=cancelled
[keepalive] evict key=...:832b2aee-... reason=no-park:cancelled
[sandbox-agent] ignoring pointer to sandbox=daytona/519ebd9b-... destroyed by this runner

Fix

Decide the settled user Stop first, in services/runner/src/engines/sandbox_agent/engine.ts. Every other disconnect still destroys and the parked entry still expires on its own TTL. The rule the disconnect check exists for is intact: it stops an unattended session being kept warm on a guess, and a Stop is not a guess. It is an authenticated command the API recorded durably, from a user who is still on the page and about to type.

One existing assertion changed on purpose. keeps destroying on client disconnect, settled cancel or not pinned the old behavior for exactly this case. It is replaced by a pair that asserts the settled Stop parks and that every other disconnect still destroys.

Tests

A dispatch-level replay in session-keepalive-dispatch.test.ts drives runWithKeepalive with the real SessionPool: turn 1 parks warm, turn 2 hits it, mid-turn the client disconnects and the run signal aborts with the user-Stop label, and the pool entry must come back at state idle with the environment never destroyed.

Check Result
pnpm run typecheck clean
pnpm run test:unit -- --maxWorkers=3 163 files, 2711 tests, 0 failed
The same two files with the fix reverted 2 failed, 58 passed

Follow-up for the reviewer

The runner predicate is identical on feat/session-durable-cancel, which is protected only on the desktop web client by a frontend commit that keeps the stream attached until a terminal event arrives. Any other client that closes its stream on Stop, or a user who closes the tab right after Stop, loses the warm sandbox there too. That branch should take this same change.

Two smaller things worth filing. The eviction log cannot tell the two refusals apart, since no-park:cancelled prints both when the harness never settled and when the client had gone. And the stop-warm matrix cell is driven by a QA driver that never disconnects, so it cannot see this class of defect at all.

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
@mmabrouk
mmabrouk force-pushed the feat/session-durable-approvals branch from 9e878ea to 9fdf78b Compare September 5, 2026 11:41
@mmabrouk
mmabrouk changed the base branch from feat/session-execution-watchdog to feat/session-approvals-queue September 5, 2026 11:41
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Website preview

Preview URL: https://pr-6530-agenta-website-preview.mahmoud-637.workers.dev

Built from 9fdf78b43a439eb009bb830b338bf1f5efde586c. This comment updates in place on every push.

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
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 mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent-generated Codex review, low weight.

SHIP

Re-checked only the rebase adaptation (15d58add1c..9e878ea40bc8721c29f0..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.

@mmabrouk
mmabrouk merged commit 30613c7 into feat/session-approvals-queue Sep 5, 2026
73 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1 points Created by Linear-GitHub Sync lgtm This PR has been approved by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant