Repository navigation
Conversation
5ac87ab to
4972bde
Compare
4972bde to
2eafd11
Compare
2eafd11 to
270ea5c
Compare
This comment has been minimized.
This comment has been minimized.
| 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') |
There was a problem hiding this comment.
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
|
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: After, pending, forked, and reverted: |
749d4b4 to
8d4cff0
Compare
81a774b to
207517f
Compare
| // 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; |
There was a problem hiding this comment.
Wouldn't this be covered by RuntimeState crashed?
There was a problem hiding this comment.
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.
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>
207517f to
49fecd4
Compare
…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
left a comment
There was a problem hiding this comment.
🤖 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.
| const state = instance.data?.state; | ||
| const canSend = state === "ready"; | ||
| const recoveryNeeded = instance.data?.recoveryNeeded === true; | ||
| const canSend = state === "ready" && !recoveryNeeded; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 RecoverRejectedSendinfinishSend
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>
|
Added |
codyhartsook
left a comment
There was a problem hiding this comment.
🤖 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
9e26908withautomaticRecovery: 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
left a comment
There was a problem hiding this comment.
🤖 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( |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?




Stacked on #3395.
Recover crashed runtimes during A2A sends when
config.sessions.automaticRecovery: true. The setting defaults tofalse.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
RevertSessioncalls 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_ACCEPTEDbefore the first stream frame, with the same message ID and a 30-second bound. Session reads do not query Substrate.Validation: