Skip to content

fix(api): expose manual crashed-session revert - #3396

Closed
adalundhe wants to merge 16 commits into
fix/session-recovery-workflowfrom
fix/session-revert-rpc
Closed

adalundhe wants to merge 16 commits into
fix/session-recovery-workflowfrom
fix/session-revert-rpc

Conversation

@adalundhe

@adalundhe adalundhe commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Expose SessionService.RevertSession through the public API and generated clients.

The service requires Session update access and uses the existing workflow. RPC tests cover ownership, read-only shares, identity checks, durable results and retry outcomes. The worker-loss E2E exercises explicit recovery and a subsequent completed message.

Stack

Split requested in the review discussion.

Part 3 of 6. 15 files, +753/-111 against the immediate parent.

Part Scope Review
1 add durable recovery primitives PR
2 implement manual crashed-runtime recovery PR
3 expose manual crashed-session revert PR
4 report recovery and preserve discarded message references PR
5 offer manual recovery for crashed conversations PR
6 add opt-in automatic crash recovery PR

Parts 2–6 form a GitHub stack on main, so CI runs on each part. A stack cannot include a fork branch, so part 2 targets main and also shows part 1's commit until #3255 merges. Parts 3–6 target their immediate parents for a focused diff. The stack is rebased onto main after #3409, which removed the public Session suspend and resume controls.

Testing

On main after #3409, RevertSession is the only Session RPC this part adds. Protobuf lint and official generation pass. The worker-loss E2E holds a turn open to keep the Actor running, because only an interaction runs it now; E2E compiles. Session, gRPC and MCP race suites pass at the top of the stack, except TestSandboxTemplateCatalog, which needs envtest binaries.

Live cluster E2E runs in this PR's CI.

Upstream CI runs on this PR as part of the stack on main.

🤖 Generated with Claude Code

@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.

Reviewed with #3409 landing first and this stack rebased on top.

Comment thread go/core/test/e2e/session_revert_test.go Outdated
}
return err == nil, err
}))
_, err = fixture.sessions.ResumeSession(fixture.ctx, &apiv1.ResumeSessionRequest{SessionId: fixture.sessionID})

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.

Once #3409 lands first, SuspendSession, ResumeSession, and their request messages are gone, so this E2E package no longer compiles. Can we keep the automatic snapshot established above and wake the Actor through a normal A2A interaction before inducing worker loss? That exercises the lifecycle available after the rebase while preserving the healthy-runtime rejection, Actor identity, history, and post-revert interaction checks.

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 in the rebase onto #3409 (08019da1). The E2E no longer calls Suspend or Resume. It keeps the automatic snapshot and holds a second A2A turn open at the model, so the Actor is running when its worker is deleted. It also keeps the checks for healthy-runtime rejection, Actor UID, retained history and the follow-up turn. f0251704 additionally checks the refusal reason on the healthy revert, and on a revert retried after recovery. On a kind cluster with gVisor workers, it passes for all four harnesses:

--- PASS: TestSessionRevertCrashedRuntime (89.75s)
    --- PASS: TestSessionRevertCrashedRuntime/kagent (23.78s)
    --- PASS: TestSessionRevertCrashedRuntime/codex (26.63s)
    --- PASS: TestSessionRevertCrashedRuntime/claude (21.71s)
    --- PASS: TestSessionRevertCrashedRuntime/byo-adk (17.64s)

e2e-summary.txt

@adalundhe
adalundhe force-pushed the fix/session-recovery-workflow branch from bc2b0f4 to dbd3b00 Compare October 27, 2026 21:43
@adalundhe
adalundhe force-pushed the fix/session-revert-rpc branch from 0a916a7 to c55f3cf Compare October 27, 2026 21:43
@adalundhe
adalundhe force-pushed the fix/session-recovery-workflow branch from dbd3b00 to aac74f8 Compare October 27, 2026 21:51
@adalundhe
adalundhe force-pushed the fix/session-revert-rpc branch from c55f3cf to 489dcb0 Compare October 27, 2026 21:51
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Oct 27, 2026
@adalundhe
adalundhe removed this pull request from stack #3405 October 28, 2026 15:09
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
@adalundhe
adalundhe added this pull request to stack #3421 October 28, 2026 15:17
@adalundhe
adalundhe force-pushed the fix/session-recovery-workflow branch from aac74f8 to dfc3bcf Compare October 28, 2026 15:18
@adalundhe
adalundhe force-pushed the fix/session-revert-rpc branch from 489dcb0 to 08019da Compare October 28, 2026 15:18
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Oct 28, 2026

@jjamroga jjamroga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The RPC itself makes sense to me. After #3409 there's no other way to say "restore a confirmed-crashed runtime," and an explicit action beats overloading an existing one. Auth, validation, the handler and generated code all look good (make proto-generate is clean). My questions are mostly about status codes, since docs/lifecycle-retries.md turns them into client retry behavior.

Comment thread docs/lifecycle-retries.md Outdated

Retry an interrupted revert against the same Session ID. Pending recovery retains its operation across API restarts.
The lifecycle worker retries already admitted recoveries; it does not initiate recovery for other crashed Sessions.
After recovery succeeds, another revert returns `FAILED_PRECONDITION` because the Actor is healthy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Telling "already recovered" apart from "can't recover": if the UNAVAILABLE → FAILED_PRECONDITION change on service.go:298 lands, FAILED_PRECONDITION means two different things here: "the Actor is healthy" (including a retry after a revert whose response got lost) and "the Actor is gone/replaced, nothing to recover." The first is basically success for the client, the second isn't. Should this say to check GetSession (READY with no Failure = done), or would distinct messages/details be better?

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.

I went with distinct details. Checking GetSession cannot separate the two cases: a Session whose Actor was replaced is also READY with no Failure. Each refusal in f0251704 now carries a google.rpc.ErrorInfo in domain kagent.dev. KAGENT_SESSION_RUNTIME_NOT_CRASHED covers a healthy or suspended runtime, including a retry after a lost success response. KAGENT_SESSION_RUNTIME_UNRECOVERABLE covers a missing or replaced Actor. docs/lifecycle-retries.md gives the client action for each, so clients need not parse the message. On the cluster, a revert retried after a successful recovery returns:

step=crashed-actor        code=OK state=RUNTIME_STATE_READY
step=retry-after-recovery code=FailedPrecondition reasons=[kagent.dev/KAGENT_SESSION_RUNTIME_NOT_CRASHED]

after/revert-outcomes.txt

Comment on lines +294 to +296
if errors.Is(err, database.ErrConflict) {
return nil, serviceerrors.NewAborted(err.Error(), err)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Small one: ActorWorkflow.Revert returns ErrConflict for anything that isn't READY/NONE (recovery.go:61-63), which lands here as ABORTED. That includes a Session suspended by scheduled-run deadline cleanup, where nothing is in progress, so retrying never converges. FAILED_PRECONDITION seems like a better fit there, keeping ABORTED for real contention.

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.

Good catch, fixed in f0251704. Revert now returns ABORTED only while a lifecycle operation is active, which a retry outlasts. A Session at rest outside READY returns FAILED_PRECONDITION with KAGENT_SESSION_RUNTIME_NOT_CRASHED. That includes one suspended by deadline cleanup, which waits for an interaction to resume it. TestExplicitRevertSeparatesContentionFromRestingState pins a SUSPENDED Session at rest as a precondition failure, and suspension, resume and creation in progress as conflicts:

--- PASS: TestExplicitRevertSeparatesContentionFromRestingState/suspended_by_deadline_cleanup
--- PASS: TestExplicitRevertSeparatesContentionFromRestingState/suspension_in_progress
--- PASS: TestExplicitRevertSeparatesContentionFromRestingState/resume_in_progress
--- PASS: TestExplicitRevertSeparatesContentionFromRestingState/creation_in_progress

unit-tests.txt

return nil, serviceerrors.NewAborted(err.Error(), err)
}
if err != nil {
return nil, serviceerrors.NewUnavailable("Failed to revert Session", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1, and the tests now lock this in: session_recovery_test.go:151 and :155 expect Unavailable for a same-name replacement and a missing Actor. The architecture doc says these "cannot authorize a revert" (runtime-and-lifecycle.md:212), but the errors table tells clients to retry UNAVAILABLE, so a client following the docs just retries until its deadline.

Should the READY branch in ActorWorkflow.Revert wrap these with ErrFailedPrecondition? Background retries go through the FAILED/RESUME branch, so they're unaffected, and transient Substrate/DB errors still map to UNAVAILABLE. I tried it locally (+11/−1 in recovery.go):

actor, err := w.actors.GetActor(ctx, revision.ActorTemplateAtespace, substrate.ActorName(session.Id))
if status.Code(err) == codes.NotFound {
	return nil, fmt.Errorf("runtime actor is missing: %w", database.ErrFailedPrecondition)
}
if err != nil {
	return nil, err
}
if !validActorIdentity(actor, revision, substrate.ActorName(session.Id)) {
	return nil, fmt.Errorf("runtime actor identity or template changed: %w", database.ErrFailedPrecondition)
}
if err := w.verifyActor(ctx, session, revision, actor); errors.Is(err, database.ErrNotFound) {
	return nil, fmt.Errorf("runtime actor was replaced: %w", database.ErrFailedPrecondition)
} else if err != nil {
	return nil, err
}

With the two assertions flipped to FailedPrecondition, TestRevertSessionThroughGRPC (all 7 cases) and the session service tests pass.

adalundhe and others added 2 commits November 3, 2026 09:56
…ce worker 0

Start ran the crash-recovery scan inline on quiescence worker 0, between idle
claims. A slow idle suspension on that worker held every recovery retry for as
long as the suspension took, and a slow revert held up idle work in turn. No
test exercised the scan through Start: the existing tests called
recoverSessionPage directly, and the Start-level store stub returned no
candidates.

The scan now runs in its own goroutine with its own ticker, at the same
recovery interval. The four quiescence workers return to their shape before
recovery existed. The page cursor still carries across ticks, so a backlog
larger than one page cannot starve the Sessions past it.

TestStartRetriesAdmittedRecoveryWhileIdleWorkBlocks admits a recovery whose
first revert fails, then starts the workflow with all four quiescence workers
blocked in idle suspensions. The Session must reach READY while they stay
blocked.

Verified: the new test fails on the previous Start ("Condition never
satisfied") and passes with this change. go test for
./internal/service/..., ./internal/substrate/... and ./internal/database/...
passes against Postgres; golangci-lint reports 0 issues.

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

Crash recovery had its own copy of execute: claim, GetActor, verify, revert,
finish. It claimed before its reads, and a released claim keeps executor_id.
One failed GetActor therefore made Delete refuse the Session with
"begin Session operation: resource conflict" until recovery succeeded. A
deleted Actor made that permanent. Execute also sent every operation on a
crashed Session into the recovery copy, so even a Delete that superseded the
recovery would have returned a conflict.

Recovery now runs through execute like suspend, resume and delete.
substrate.PrepareActorRevert does the read-only checks before the claim, and
ApplyActorTransition issues RevertActor, which moves onto LifecycleClient. The
checks are unchanged: the Actor keeps its UID, ends SUSPENDED, and restores the
durable snapshot observed before the revert. A SUSPENDED Actor still completes a
recovery whose revert response was lost. A failed preparation keeps the RESUME
intent instead of clearing it, because admission already failed the
interrupted turn. Nothing was issued, so Delete can supersede it.

Rejected: keeping the separate path and only moving its reads above the claim.
That fixes the stuck Delete, but fixes to execute would still have to land
twice.

Verified: TestDeleteSupersedesUnissuedCrashRecovery covers an unreadable and a
missing Actor; both fail on the previous code with "resource conflict" and
pass here. TestActorRevertRestoresTheObservedSnapshotInPlace covers the revert
transition's states and its UID and snapshot checks. go test for
./internal/service/..., ./internal/substrate/... and ./internal/database/...
passes against Postgres; golangci-lint reports 0 issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Ada Lundhe and others added 10 commits November 3, 2026 11:23
…t UNAVAILABLE

RevertSession reported a missing or same-name-replaced Actor as
`UNAVAILABLE`. The retry guide tells clients to retry that code, so a client
that followed it retried a runtime no revert can restore until its deadline.
These refusals now return `FAILED_PRECONDITION`. Transient Substrate and
database failures still return `UNAVAILABLE`.

`FAILED_PRECONDITION` then meant two opposite things: the runtime is already
healthy, which includes a retry after a lost success response, or it can never
be recovered. A `google.rpc.ErrorInfo` detail in domain `kagent.dev` now names
`KAGENT_SESSION_RUNTIME_NOT_CRASHED` or `KAGENT_SESSION_RUNTIME_UNRECOVERABLE`,
and the retry guide gives the client action for each. Telling clients to read
`GetSession` lost: a Session whose Actor was replaced is also READY with no
failure, so the Session alone cannot separate the two.

A revert of a Session at rest outside READY returned `ABORTED`. A Session
suspended by deadline cleanup stays suspended until an interaction resumes it,
so the retry `ABORTED` invites never converged. Only an active lifecycle
operation is now contention; a resting Session returns
`KAGENT_SESSION_RUNTIME_NOT_CRASHED`.

The admitted-recovery retry path is unchanged: it enters through the
FAILED/RESUME branch, which none of these checks reach.

Verified: the session and grpcserver suites pass against PostgreSQL, apart
from TestSandboxTemplateCatalog, which needs envtest binaries. That includes
TestRevertSessionThroughGRPC, which now pins the code and reason for a healthy,
replaced, missing and already-recovered runtime. golangci-lint reports 0
issues and gofmt is clean. On a kind cluster with gVisor workers, the
worker-loss revert E2E passes for all four harnesses. On the same cluster, a
revert after the Actor was deleted, or replaced under the same name, returned
`UNAVAILABLE` before this change and now returns `FAILED_PRECONDITION` with
`KAGENT_SESSION_RUNTIME_UNRECOVERABLE`. A revert retried after recovery
returns `KAGENT_SESSION_RUNTIME_NOT_CRASHED`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <ada.lundhe@solo.io>
…ery-workflow

Bring 05cfb0c into the revert RPC branch. That commit moves crash
recovery into `execute` through `substrate.PrepareActorRevert` and
`ApplyActorTransition`, so a failed Actor read leaves no claim and
Delete can still supersede the recovery.

Conflicts resolved:
- `go/core/internal/service/session/recovery.go` imports: the workflow
  branch dropped `time`, `uuid` and `proto` with `recoverCrashedSession`.
  Keep `codes` and `status`, which `Revert` uses to report a missing
  Actor as FAILED_PRECONDITION. `proto` has no remaining user here.

`Revert` merged without a textual conflict. It keeps this branch's
refusals: FAILED_PRECONDITION with `errRuntimeNotCrashed` or
`errRuntimeUnrecoverable`, and ABORTED only for an active lifecycle
operation. The admitted operation then runs through the workflow
branch's `execute` path.

Verified on linux/amd64 with Go 1.27.3: `go build` and `go vet` over
`./core/...` and `./api/...`, pinned `gofmt` on the touched files,
`make lint` on the touched packages, and `go test` over the service,
substrate, database, grpcserver and mcp packages against Postgres
testcontainers.

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

Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Main renamed SnapshotStorage.status to state (SnapshotStorageState).
Update the recovery and substrate snapshot tests to the new field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Resolve the service.go import conflict by keeping both the status/errdetails
imports this PR needs and the proto import the base added. Regenerate
sessions.pb.go and sessions_pb.ts from the merged sessions.proto instead of
hand-merging generated output.

Follow the base's API changes in this PR's tests: SnapshotStorage now
reports State instead of Status, and Session.agent is an AgentProvenance
whose namespace lives under ref.

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

Start ran the crash-recovery scan inline on quiescence worker 0, between idle
claims. A slow idle suspension on that worker held every recovery retry for as
long as the suspension took, and a slow revert held up idle work in turn. No
test exercised the scan through Start: the existing tests called
recoverSessionPage directly, and the Start-level store stub returned no
candidates.

The scan now runs in its own goroutine with its own ticker, at the same
recovery interval. The four quiescence workers return to their shape before
recovery existed. The page cursor still carries across ticks, so a backlog
larger than one page cannot starve the Sessions past it.

TestStartRetriesAdmittedRecoveryWhileIdleWorkBlocks admits a recovery whose
first revert fails, then starts the workflow with all four quiescence workers
blocked in idle suspensions. The Session must reach READY while they stay
blocked.

Verified: the new test fails on the previous Start ("Condition never
satisfied") and passes with this change. go test for
./internal/service/..., ./internal/substrate/... and ./internal/database/...
passes against Postgres; golangci-lint reports 0 issues.

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

Crash recovery had its own copy of execute: claim, GetActor, verify, revert,
finish. It claimed before its reads, and a released claim keeps executor_id.
One failed GetActor therefore made Delete refuse the Session with
"begin Session operation: resource conflict" until recovery succeeded. A
deleted Actor made that permanent. Execute also sent every operation on a
crashed Session into the recovery copy, so even a Delete that superseded the
recovery would have returned a conflict.

Recovery now runs through execute like suspend, resume and delete.
substrate.PrepareActorRevert does the read-only checks before the claim, and
ApplyActorTransition issues RevertActor, which moves onto LifecycleClient. The
checks are unchanged: the Actor keeps its UID, ends SUSPENDED, and restores the
durable snapshot observed before the revert. A SUSPENDED Actor still completes a
recovery whose revert response was lost. A failed preparation keeps the RESUME
intent instead of clearing it, because admission already failed the
interrupted turn. Nothing was issued, so Delete can supersede it.

Rejected: keeping the separate path and only moving its reads above the claim.
That fixes the stuck Delete, but fixes to execute would still have to land
twice.

Verified: TestDeleteSupersedesUnissuedCrashRecovery covers an unreadable and a
missing Actor; both fail on the previous code with "resource conflict" and
pass here. TestActorRevertRestoresTheObservedSnapshotInPlace covers the revert
transition's states and its UID and snapshot checks. go test for
./internal/service/..., ./internal/substrate/... and ./internal/database/...
passes against Postgres; golangci-lint reports 0 issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
Main renamed SnapshotStorage.status to state (SnapshotStorageState).
Update the recovery and substrate snapshot tests to the new field.

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-workflow branch from 2e4e461 to 7ee6d63 Compare November 4, 2026 17:07
@adalundhe
adalundhe force-pushed the fix/session-recovery-workflow branch from 7ee6d63 to 72d68a3 Compare November 4, 2026 17:13
…-workflow

Merge the rebased fix/session-recovery-workflow, which now sits on main
with the Substrate v1.0.0-rc2 upgrade.

recovery.go and recovery_test.go conflicted as add/add because both
sides added them after the rebase. Resolved with a three-way merge using
the pre-rebase fix/session-recovery-workflow (2e4e461) as the base; the
rebase left both files unchanged, so this branch's versions stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ada Lundhe <adalundhe@lundhe.audio>
@adalundhe
adalundhe force-pushed the fix/session-revert-rpc branch from 5916c92 to 965013c Compare November 4, 2026 17:34
@adalundhe
adalundhe force-pushed the fix/session-recovery-workflow branch from 72d68a3 to 9f76a9a Compare November 4, 2026 22:37
@adalundhe

Copy link
Copy Markdown
Contributor Author

Folded into #3395, which now carries the RevertSession RPC, its access policy and refusal reasons, and the e2e test together with the recovery workflow they use. The rest of the stack (#3397, #3398, #3399) is being rebuilt on top of #3395.

@adalundhe adalundhe closed this Nov 4, 2026
@adalundhe
adalundhe removed this pull request from stack #3421 November 5, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants