Skip to content

feat(session): add opt-in crash recovery on send - #3399

Open
adalundhe wants to merge 12 commits into
mainfrom
fix/session-recovery-automatic
Open

adalundhe wants to merge 12 commits into
mainfrom
fix/session-recovery-automatic

Conversation

@adalundhe

@adalundhe adalundhe commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #3395.

Recover crashed runtimes during A2A sends when config.sessions.automaticRecovery: true. The setting defaults to false.

The flag controls joining an admitted recovery, retrying a conflicting send reservation, and reverting after a proven unaccepted send. With it off, sends keep their existing refusals. Explicit RevertSession calls remain available.

Missing or replaced runtimes are recorded as FAILED/NONE with reason RuntimeLost. These persistence changes apply with either flag value. The flag does not control migrations, schema, or stored data.

Chat reads recovery status from stored Session state. It retries KAGENT_SEND_NOT_ACCEPTED before the first stream frame, with the same message ID and a 30-second bound. Session reads do not query Substrate.

Validation:

  • Gateway tests cover all three disabled paths and the existing enabled paths.
  • Config tests cover the default, both values, Helm rendering, and the deployment checksum.
  • PostgreSQL-backed Session, gRPC, and scheduled-run controller tests pass.
  • Protobuf lint, generation, and compatibility checks pass.
  • Go lint passes. Live crash-recovery E2E explicitly enables the flag in CI.

@adalundhe
adalundhe requested a review from a team as a code owner October 27, 2026 17:08
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Oct 27, 2026
@EItanya
EItanya added this pull request to stack #3405 October 27, 2026 18:18
@adalundhe
adalundhe force-pushed the fix/session-recovery-automatic branch from 5ac87ab to 4972bde Compare October 27, 2026 21:43
@adalundhe
adalundhe force-pushed the fix/session-recovery-automatic branch from 4972bde to 2eafd11 Compare October 27, 2026 21:51
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Oct 27, 2026
@adalundhe
adalundhe removed this pull request from stack #3405 October 28, 2026 15:09
@adalundhe
adalundhe added this pull request to stack #3421 October 28, 2026 15:17
@adalundhe
adalundhe force-pushed the fix/session-recovery-automatic branch from 2eafd11 to 270ea5c Compare October 28, 2026 15:18
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Oct 28, 2026
@blacksmith-sh

This comment has been minimized.

@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Oct 28, 2026
FROM session_record i
WHERE ($1::text = '' OR i.id > $1::uuid)
AND i.state = 'RUNTIME_STATE_FAILED' AND i.operation = 'RUNTIME_OPERATION_RESUME'
AND (($3 AND i.state = 'RUNTIME_STATE_READY' AND i.operation = 'RUNTIME_OPERATION_NONE')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. The scan needed one interval per 100 READY Sessions and spent one GetActor on each healthy runtime. As of 94026a0, each interval lists the Actors in the atespaces that hold a READY Session, in pages of 1,000, and reads only the CRASHED ones individually. Revert re-observes the Actor before it admits anything, and PostgreSQL paging now covers admitted recoveries only. On a dev fleet with about 505 READY Sessions and a 15s interval, a worker loss took 69–73s to recover before and 0.4–11s after. An idle controller went from 507 GetActor calls a minute to 6 ListActors calls (crash-detection.txt, substrate-rpcs-idle.txt):

before  READY=506  recovered 1m13.1s after the worker deletion (4.9 intervals)
after   READY=507  recovered 11s after the worker deletion (0.7 intervals)
idle 60s, interval 10s, READY=507:  before 507 GetActor   after 6 ListActors

adalundhe added a commit to adalundhe/kagent that referenced this pull request Nov 4, 2026
@adalundhe

Copy link
Copy Markdown
Contributor Author

Review follow-up: a failed automatic recovery must still leave the user a way to revert or fork. The review thread that raised this is gone, so I'm answering here.

Agreed, and my first fix (02bd7b6) only half closed this. It added Revert to the alert in automatic mode, but disabled the button while the Session's operation was RESUME. An admitted recovery keeps RESUME through every failed attempt, so after the first automatic attempt failed, Revert spun until a later attempt succeeded. 5ae5c00 disables it only while the page's own request is in flight. A click joins the admitted recovery. After merging #3398 (11f9abb, 40e3702), Fork lives on each saved snapshot's line in the transcript, which renders in automatic mode too, so there is one place to fork.

On a dev fleet I held the automatic revert failing.

Before, at 3fc96de:
before

After, pending, forked, and reverted:
pending
forked
reverted

04:55:34 ERROR recover crashed session: lifecycle operation 9e0f62b5-… remains pending: … failed at step DiscardWorker
04:56:16 CheckpointService/ForkSession  OK  45ms
04:56:27 SessionService/RevertSession   OK  51ms
after UI step: recovered=true state=RUNTIME_STATE_READY needed=false

@adalundhe
adalundhe force-pushed the fix/session-recovery-automatic branch from 749d4b4 to 8d4cff0 Compare November 4, 2026 17:34
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Nov 6, 2026
@adalundhe
adalundhe force-pushed the fix/session-recovery-automatic branch from 81a774b to 207517f Compare November 6, 2026 20:27
Comment thread proto/kagent/api/v1/sessions.proto Outdated
// Output only. Set only by GetSession: whether the recorded runtime crashed and
// needs a revert. Unset when GetSession could not observe the runtime; clients
// must not offer a revert then.
optional bool recovery_needed = 16;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wouldn't this be covered by RuntimeState crashed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes. I removed recovery_needed. A pending recovery shows as FAILED/REVERT with reason RuntimeCrashed, as before. A runtime that can't be recovered is now stored as FAILED/NONE with reason RuntimeLost. The failure field comment in the proto documents both.

Base automatically changed from fix/session-recovery-workflow to main November 6, 2026 22:09
adalundhe and others added 10 commits November 6, 2026 14:09
Add `Session.recovery` with `SessionRecovery.failed_messages`, a list of
`SessionMessageReference` task and message identities. When admission
reverts a crashed runtime, it records each user input after the
retained snapshot boundary, including accepted inputs not yet
published, in the same transaction that fails the interrupted turn.

The boundary is the newest history event tagged with the snapshot the
Actor reverts to. Without a snapshot every input is discarded. If no
event records the snapshot, the boundary is unknown and no new
annotations are added. One CTE resolves the boundary and reads the
discarded messages. Each message's identity is still validated in Go,
and a malformed event rejects the admission.

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`GetSession` now reports `recovery.needed`. It is true for an admitted
recovery, false for a Session with a lifecycle operation in flight,
and, for an idle Session, the result of the same `observeCrash` check
`RevertSession` admits by. Chat can therefore never offer a revert the
API would refuse. An observation costs one fence read and one Substrate
call. If the observation fails, needed is left unset rather than
failing the read, so an outage cannot hide history.

`GetSession` is the only read that observes Substrate. `Get`, which MCP
calls before every invoke, serves the stored record without observing.
`ListSessions`, rename, revert, and delete omit `recovery`, so a list
does not carry every conversation's annotations.

`Service.Observe` always has a workflow; transport tests use a stand-in
that reports stored annotations instead of a nil workflow.

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API client now maps `GetSession`'s `recovery` (needed and the
discarded message references) into the conversation domain type, and
adds a `revert` operation that calls `RevertSession`. The mock
transport passes recovery through. It refuses a revert with
FAILED_PRECONDITION, because fixtures have no crashed runtime that
could authorize one.

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When `GetSession` reports that a conversation's runtime needs recovery,
chat shows an alert offering to revert it to its last snapshot. The
existing fork action stays available for a saved checkpoint. An idle
conversation re-reads the Session on the existing four-second live
refresh while the tab is visible, so a crash after a turn settles is
found without keeping the one-second lifecycle poll running. The shared
page does the same.

Inputs that the restored runtime does not remember are marked "Not
remembered after recovery" across their whole turn, and the public
transcript is kept. Forking a crashed conversation is left to the
checkpoint lines.

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestMCPToolApproval and TestSessionAskUserSurvivesSuspension wait for the
Actor to suspend, then send the reply once. Waking the Actor can fail before
the runtime accepts the reply. In a gVisor run, the restored runtime's
TaskStore dial to the controller timed out, so the gateway revoked the dispatch
and answered KAGENT_SEND_NOT_ACCEPTED. The test treated that retryable answer
as a failure.

Both resumes now go through sendMessageWithRetry, which every other send in
the suite already uses. The server returns this error only after proving the
input was not persisted, and a repeated message ID returns the accepted task,
so the retry cannot duplicate the reply.

Verified with go vet on the e2e package; the suite runs in CI.

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The base branch now returns the Session from RevertSession when it is
READY and its recorded Actor is SUSPENDED, so a retry after a lost
revert response succeeds. TestSessionRecoveryObservation still expected
KAGENT_SESSION_RUNTIME_NOT_CRASHED for its healthy case, whose Actor is
suspended.

The healthy case now expects the unchanged Session, and a running case
keeps the NOT_CRASHED refusal covered. Every case asserts that only a
confirmed crash reaches RevertActor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
RevertSession now returns the Session for a runtime that is not
crashed, so chat no longer needs the NOT_CRASHED re-read.

GetSession reports recovery as `optional bool recovery_needed` instead
of a SessionRecovery message. The discarded-message annotations
(failed_messages) are removed: the admission CTE that recorded them,
the transcript marking, the shared page's Session refresh, and
withoutRecovery, which existed only because the annotations were stored
and grew with each revert. Showing users what a revert lost is a
separate follow-up.

Revert also stays usable while an earlier attempt's recovery is still
admitted. The alert disables the button only while this page's own
request is in flight (isReverting), which is separate from isRecovering.
This came in with the automatic-recovery commit, which is no longer in
this history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Remove comments added for recovery observation and the chat Revert flow
that narrate the code, restate names, repeat assertions, or describe
earlier behavior. Keep the ordering, polling, and retry rationale,
shortened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
… guard

- The CheckpointDivider fork check fixes forking from non-READY
  checkpoints, which predates crash recovery, so it is not part of this
  PR.
- The revert double-click ref is unnecessary: a second RevertSession
  joins the same REVERT or returns the Session.
- refreshConversation had one caller, so the live refresh inlines
  Promise.allSettled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Substrate cannot resume a crashed Actor, so an A2A send to one fails
before the runtime accepts it. The gateway revokes the dispatch and
answers KAGENT_SEND_NOT_ACCEPTED, and every retry failed the same way.

When the gateway proves a failed send was not accepted, it now runs the
Session's Revert before answering. A healthy runtime returns at once. A
crashed one is reverted in place, so the client's ordinary retry
reaches the restored runtime. A send to a Session with recovery already
admitted joins that recovery instead of being refused. The revert uses
the authorization the send already checked and continues if the caller
disconnects. A missing or replaced Actor answers the send with a
non-retryable error rather than the retry signal.

No background work runs: recovery starts only from a client request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
@EItanya
EItanya force-pushed the fix/session-recovery-automatic branch from 207517f to 49fecd4 Compare November 6, 2026 22:09
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Nov 6, 2026
shashankram pushed a commit to shashankram/kagent that referenced this pull request Nov 6, 2026
…dev#3395)

## Summary

When Substrate reports a Session's Actor `CRASHED`, a caller can revert
it to its last durable snapshot through `SessionService.RevertSession`.
Recovery runs only when a client asks: an admitted recovery is a durable
`RUNTIME_OPERATION_REVERT` operation, and a retried `RevertSession`
joins it, the same way Create and Delete retries work.

### How a revert works

1. **Observe.** `observeCrash` reads the Session's generation (lifecycle
operation ID, recorded Actor UID, Actor template) in one statement, then
reads the Actor from Substrate. A missing, replaced, or retemplated
Actor is unrecoverable, never a crash.
2. **Admit.** `BeginCrashedSessionRecovery` runs one transaction under
the Session lock. It:
- checks the Actor UID and generation, and that no checkpoint is being
created;
- fails the interrupted turn with a timeline-positioned crash note,
keeping waits the snapshot captured. Publishing saved results and
retiring idle-work markers reuse the helpers Session deletion uses;
   - starts a REVERT operation.
3. **Execute.** The shared `execute` path reverts the Actor in place.
Substrate restores the last completed durable snapshot. Every suspend of
a kagent Actor is synchronous, so that is the snapshot admission settled
against. `applyRevert` requires the same Actor back, SUSPENDED. If
preparation fails, the REVERT is kept for a retry.

### API

- `RevertSession` validates `session_id` as a UUID and needs
`AccessUpdate`, so a read-only share cannot invoke it.
- A runtime that is not crashed returns the Session unchanged. That is
also what a retry sees after a revert whose response was lost.
- A missing or replaced Actor returns `FAILED_PRECONDITION`. Contention
returns `ABORTED`, and transient failures return `UNAVAILABLE`.
- Delete supersedes a recovery even after a revert was issued. A revert
restores the recorded Actor in place and creates nothing that Delete
could miss.

### Schema

`000002_session_revert_operation.sql` adds `RUNTIME_OPERATION_REVERT` to
the `runtime_instance` operation CHECK.

## Testing

- `make -C go lint`, `make proto-generate`
- `go test` for `service/session`, `substrate`, `grpcserver` (revert),
and `pkg/migrations` (up/down round trip)
- PostgreSQL store tests, including `TestInlineSQLPrepares`
- E2E: `go/core/test/e2e/session_revert_test.go`

Stack: this PR → kagent-dev#3399 (GetSession recovery report and chat).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

@EItanya EItanya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI-generated review. Comment left by Claude on behalf of @EItanya.

Thanks for adding recovery on send, I think that's the right place for it. A crashed Actor can't take a send anyway, so reverting before answering KAGENT_SEND_NOT_ACCEPTED means API clients recover with the retry they already do. A few things now that it's in, inline.

Comment thread ui/src/pages/AgentChatPage.tsx Outdated
const state = instance.data?.state;
const canSend = state === "ready";
const recoveryNeeded = instance.data?.recoveryNeeded === true;
const canSend = state === "ready" && !recoveryNeeded;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With send recovering, do we still need recovery_needed?

The chat still blocks sending when recoveryNeeded is true and asks the user to click Revert. Send now does the same Revert, so we have two recovery paths for one case. The chat could just send, and the existing KAGENT_SEND_NOT_ACCEPTED retry would land on the restored runtime.

If we drop the alert, recovery_needed, Observe, and RecoveryNeeded can go too. That also takes care of the polling cost: today GetSession calls Substrate GetActor on every 4s idle refresh and every 1s poll during a turn, for a Session that's almost always healthy, and the 5s timeout in RecoveryNeeded is longer than both intervals so a slow Substrate stacks up reads. An admitted-but-unfinished recovery already shows as FAILED/REVERT with RuntimeCrashed, so the UI can show "recovering" from stored state without asking Substrate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I removed the alert, recovery_needed, Observe, and RecoveryNeeded, along with the 4s Session re-read. No Session read calls Substrate now.

Chat sends normally. The UI's send path had no retry for KAGENT_SEND_NOT_ACCEPTED (only the CLI did), so I added one: same message ID, before any frame arrives, within a 30s window. Chat shows "recovering" from FAILED/REVERT and "runtime lost" from FAILED/NONE + RuntimeLost, both read from stored state.

One case send alone didn't cover: a crash during a turn leaves that task WORKING, and active work refuses every send, so no send ever reached the crashed Actor. A send refused for active work now runs Revert once and retries its reservation. A crashed runtime gets restored and the send goes through. A busy healthy runtime refuses it as before. That costs one Substrate read per send to a busy Session.


// ErrRuntimeUnrecoverable answers a send to a Session whose recorded Actor is
// missing or was replaced. Retrying cannot succeed.
var ErrRuntimeUnrecoverable = a2atype.NewError(a2atype.ErrUnsupportedOperation, "Session runtime cannot be recovered; start another conversation")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An unrecoverable runtime isn't recorded anywhere

When the Actor is missing or replaced the send gets this error, but the Session stays READY/NONE. Every later send repeats the GetActor and gets the same answer, and GetSession has nothing to show (RecoveryNeeded returns nil for this case). This is what my comment on the proto was getting at: once we've seen it, can we write it down as state? E.g. FAILED/NONE with a failure reason, written in one transaction under the operation ID guard. Then sends fail fast from PostgreSQL, the UI can show "start a new conversation" from GetSession, and nothing needs a per-read observation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. When Revert finds the Actor missing, replaced, or retemplated, it calls RecordLostSessionRuntime. In one transaction, guarded by the operation ID it observed, that sets FAILED/NONE with reason RuntimeLost, fails open tasks, and clears any pending dispatch. A newer generation returns a conflict and leaves the Session alone. This applies to an idle revert and to an admitted recovery whose Actor disappears.

After that, sends and RevertSession fail from PostgreSQL without asking Substrate. Delete still works.

// checked, and returns how to answer that send. A healthy runtime returns at once.
// The revert outlives a caller that disconnects, so a retry finds it finished.
func (s *InteractionService) recoverRuntime(ctx context.Context, session *apiv1.Session) error {
_, err := s.sessions.workflow.Revert(context.WithoutCancel(ctx), session)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Send authorizes Revert with a weaker verb

Service.Revert requires VerbUpdate (service.go:306). A send to an existing context without a task ID resolves the Session through storedSession(ctx, auth.VerbCreate, ...) (interactions.go:353), and this calls workflow.Revert directly. So a caller who can send but not update the Session can now revert it. Is that intended? If send-implies-revert is the contract I think it should be explicit in the authorizer, otherwise can we check VerbUpdate before reverting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not intended. Recovery from a send now goes through Service.Revert, so it needs VerbUpdate, the same as RevertSession. A caller who can send but not update gets permission denied. On the busy-send path they get the original busy error instead.

store *gatewayTestStore
}

func (w gatewayTestWorkflow) Revert(_ context.Context, session *apiv1.Session) (*apiv1.Session, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tests don't exercise an actual revert

This stub always returns the Session unchanged, so the gateway tests only count calls, and TestSendRecoveryAnswersTheSend only maps errors. Nothing covers a send to a crashed Actor being reverted and the retry being accepted. Since this is runtime lifecycle behavior, can we add an e2e? session_revert_test.go could send instead of calling RevertSession.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added. TestSessionRevertCrashedRuntime is now TestSessionSendRecoversCrashedRuntime. After the worker is deleted mid-turn, the test sends an ordinary message with retry. It then checks that the send is answered by the restored runtime, the Actor UID is unchanged, and the completed task is preserved. A RevertSession call after that returns READY.

Unit coverage also includes the lost-runtime transition against PostgreSQL and the busy-send probe (healthy and crashed).

func (s *InteractionService) RecoverRejectedSend(ctx context.Context, agent types.NamespacedName, message *a2atype.Message) error {
session, err := s.messageSession(ctx, agent, message)
if err != nil {
return ErrSendNotAccepted

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Small one: this maps any messageSession error to ErrSendNotAccepted, including not found and permission denied. Those become endless retries instead of the real error. Can we return the error as is?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. RecoverRejectedSend now returns the error as is, and there's a test for not found and permission denied.

Address review feedback on crash recovery:

- Drop Session.recovery_needed, Service.Observe, and
  ActorWorkflow.RecoveryNeeded. No Session read calls Substrate. Chat
  sends normally and retries KAGENT_SEND_NOT_ACCEPTED with the same
  message ID; it shows a pending recovery and a lost runtime from the
  stored Session state.
- Record a missing, replaced, or retemplated Actor as FAILED/NONE with
  failure reason RuntimeLost, in one transaction guarded by the observed
  operation ID. Open tasks fail and a pending dispatch is cleared. Later
  sends and reverts fail from PostgreSQL without asking Substrate.
- Recover through Service.Revert so a send needs update access to
  revert, as RevertSession does.
- Return Session errors from RecoverRejectedSend instead of mapping them
  to KAGENT_SEND_NOT_ACCEPTED.
- A crash during a turn leaves the turn active, which refuses every
  send. A send refused for active work now runs Revert once and retries
  its reservation, so a crashed runtime is restored and a busy healthy
  one refuses the send as before.
- The crash e2e recovers through a send instead of RevertSession.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>

@iplay88keys iplay88keys left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we make it so that automatic recovery is behind a feature flag that is off by default?

The flag only needs to cover the send path:

  • the recovery in resolveSend
  • the conflict retry in PrepareSend
  • RecoverRejectedSend in finishSend

When the flag is off, a send to a crashed Session should keep the refusal it gets today.

Please keep the flag out of the migrations and the stored data. Schema and persistence changes should always apply. Then upgrades and rollbacks stay safe for each flag value, and each version can read the stored data. We cannot ship breaking changes now, so this is important.

Add sessions.automaticRecovery, disabled by default. Gate all three send recovery paths while keeping explicit revert, migrations, and persistence unconditional. Enable recovery in the CI fleet and cover disabled refusals and config rendering. Gateway, config, PostgreSQL Session, gRPC, scheduled-run, protobuf, and lint checks pass.

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
@adalundhe adalundhe changed the title feat(session): report and offer crash recovery feat(session): add opt-in crash recovery on send Nov 9, 2026
@adalundhe

Copy link
Copy Markdown
Contributor Author

Added config.sessions.automaticRecovery, off by default, in 9e269080. It gates all three send recovery paths. Explicit revert stays available, and migrations and persistence remain unconditional. Local checks pass; CI is running.

@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Nov 9, 2026

@codyhartsook codyhartsook left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI-assisted review.

A failed recovery can leave chat stuck with the flag on

On kind, I took atelet down while chat's send ran the recovery, and the Session stayed FAILED/REVERT. It was still stuck three minutes after atelet came back. After a reload, chat says the runtime "is being restored", but the composer is disabled. Unless I missed a path, the user has no way to recover from chat.

Today canSend (AgentChatPage.tsx:345) is state === "ready", so a recovering Session gets a disabled composer. I'd recommend also allowing a pending recovery, with alert copy that says to send a message:

const canSend = state === "ready" || instance.data?.operation === "revert";

A running revert and a failed one look the same in the stored Session (FAILED/REVERT), and a send handles both. It rejoins a failed recovery (interactions.go:500), and while a revert runs it's refused as not accepted, so chat's existing resend lands once it finishes. I tried this on kind, and the next message restored the runtime and was answered.

What I checked
  • Kind cluster at 9e26908 with automaticRecovery: true, chat driven in a browser.
  • Worker pod deleted mid-turn, then a send from chat with atelet down.
  • The same run with a local patch that enables the composer during revert.

@EItanya EItanya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI-generated review. Comment left by Claude on behalf of @EItanya.

Thanks for working through all of these, the stored RuntimeLost state and dropping the Substrate read from GetSession make this a lot simpler. A few things now that recovery on send is behind automaticRecovery, inline.

* Read from the stored Session alone. A pending crash recovery and a runtime that is
* gone are both FAILED, and only the operation and failure reason tell them apart.
*/
function notReadyCopy(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the flag off, chat has no way to recover a crashed conversation

The Revert alert is gone, and send only recovers when automaticRecovery is on, which is off by default. So in a default install, a crashed conversation looks READY in chat, every send comes back KAGENT_SEND_NOT_ACCEPTED, the new retry loop runs for 30s, and then the user gets an error with no hint that the runtime crashed or what to do. That's the same place we were before this PR, plus 30s of retries.

Can we keep an explicit Revert in the UI for this case? E.g. when the 30s window runs out on KAGENT_SEND_NOT_ACCEPTED, show "the runtime may have crashed" with a Revert button that calls RevertSession. Revert returns a healthy Session unchanged, so it's safe to offer on a guess, and it's an explicit user action, so it fits the flag being off. With the flag on, the user wouldn't see it unless recovery itself keeps failing.

// crashed Actor cannot be resumed, so the refusal would repeat on every retry;
// reverting it first lets the caller's retry reach the restored runtime.
func (s *InteractionService) RecoverRejectedSend(ctx context.Context, agent types.NamespacedName, message *a2atype.Message) error {
if !s.automaticRecovery {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the flag off, a lost runtime is never recorded from a send

This returns ErrSendNotAccepted before looking at anything when the flag is off, so a send to a missing or replaced Actor stays retryable forever and the Session stays READY/NONE. Only an explicit RevertSession records RuntimeLost. Recording the loss doesn't revert or discard anything, so I'm not sure it needs to be behind the flag. @iplay88keys, would you be ok with the rejected-send path recording RuntimeLost regardless of the flag, and only the revert being gated?

* How long a send keeps re-sending a message the gateway proved it did not accept.
* The window bounds the retries, not how long an accepted turn may run.
*/
const UNACCEPTED_SEND_WINDOW_MS = 30_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the gateway's retryAfterMs: 100, this window is up to ~300 sends per message, and each one goes through dispatch to the runtime (and with the flag on, a GetActor). That's fine for the busy-dispatch case that clears quickly, but for a crashed runtime with the flag off it's the whole window. Can we back off between retries, e.g. double from retryAfterMs up to a few seconds?

generation *database.SessionGeneration
actor *ateapipb.Actor
// lost replaces actor with why the recorded Actor cannot be recovered.
lost string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lost sits next to actor with a comment saying one replaces the other, so a caller can build an observation with both, or with neither. Could observeCrash return the loss separately, e.g. a typed actorLostError{reason} that Revert checks with errors.As, so the observation only ever carries an Actor?

Small related one: lifecycleIdle is now defined in both database and service/session with the same body; can the session package use the database one?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants