From 4300c0760003ab16512c3691c5879c23bcf2afc8 Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Wed, 4 Nov 2026 15:10:43 -0800 Subject: [PATCH 01/12] feat(session): record inputs a crash recovery discards 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 Co-Authored-By: Claude Opus 5.5 --- go/api/gen/kagent/api/v1/sessions.pb.go | 410 ++++++++++++------ go/core/internal/database/session_recovery.go | 76 ++++ .../session_recovery_messages_test.go | 138 ++++++ proto/kagent/api/v1/sessions.proto | 19 + ui/src/generated/kagent/api/v1/sessions_pb.ts | 103 ++++- 5 files changed, 587 insertions(+), 159 deletions(-) create mode 100644 go/core/internal/database/session_recovery_messages_test.go diff --git a/go/api/gen/kagent/api/v1/sessions.pb.go b/go/api/gen/kagent/api/v1/sessions.pb.go index ab556d6f9b..ade832c216 100644 --- a/go/api/gen/kagent/api/v1/sessions.pb.go +++ b/go/api/gen/kagent/api/v1/sessions.pb.go @@ -93,7 +93,9 @@ type Session struct { Name string `protobuf:"bytes,13,opt,name=name,proto3" json:"name,omitempty"` // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. - ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // Output only. Set only by GetSession; every other response leaves it unset. + Recovery *SessionRecovery `protobuf:"bytes,16,opt,name=recovery,proto3" json:"recovery,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -212,6 +214,124 @@ func (x *Session) GetContextId() string { return "" } +func (x *Session) GetRecovery() *SessionRecovery { + if x != nil { + return x.Recovery + } + return nil +} + +type SessionRecovery struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether the recorded runtime crashed and needs a revert. Unset when + // GetSession could not observe the runtime; clients must not offer a revert then. + Needed *bool `protobuf:"varint,1,opt,name=needed,proto3,oneof" json:"needed,omitempty"` + // User messages the restored runtime does not remember, from every revert of + // this Session. Each appears once. Their A2A history remains available, + // including completed task results, so the list lasts as long as the + // Session and grows only when a revert discards input. + FailedMessages []*SessionMessageReference `protobuf:"bytes,3,rep,name=failed_messages,json=failedMessages,proto3" json:"failed_messages,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRecovery) Reset() { + *x = SessionRecovery{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRecovery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRecovery) ProtoMessage() {} + +func (x *SessionRecovery) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRecovery.ProtoReflect.Descriptor instead. +func (*SessionRecovery) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{2} +} + +func (x *SessionRecovery) GetNeeded() bool { + if x != nil && x.Needed != nil { + return *x.Needed + } + return false +} + +func (x *SessionRecovery) GetFailedMessages() []*SessionMessageReference { + if x != nil { + return x.FailedMessages + } + return nil +} + +// Message IDs are unique within a task, not across a conversation. +type SessionMessageReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionMessageReference) Reset() { + *x = SessionMessageReference{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionMessageReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionMessageReference) ProtoMessage() {} + +func (x *SessionMessageReference) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionMessageReference.ProtoReflect.Descriptor instead. +func (*SessionMessageReference) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{3} +} + +func (x *SessionMessageReference) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *SessionMessageReference) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + type AgentProvenance struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -222,7 +342,7 @@ type AgentProvenance struct { func (x *AgentProvenance) Reset() { *x = AgentProvenance{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -234,7 +354,7 @@ func (x *AgentProvenance) String() string { func (*AgentProvenance) ProtoMessage() {} func (x *AgentProvenance) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -247,7 +367,7 @@ func (x *AgentProvenance) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentProvenance.ProtoReflect.Descriptor instead. func (*AgentProvenance) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{2} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{4} } func (x *AgentProvenance) GetRef() *ResourceReference { @@ -276,7 +396,7 @@ type CreateSessionRequest struct { func (x *CreateSessionRequest) Reset() { *x = CreateSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -288,7 +408,7 @@ func (x *CreateSessionRequest) String() string { func (*CreateSessionRequest) ProtoMessage() {} func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -301,7 +421,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{3} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{5} } func (x *CreateSessionRequest) GetAgent() *ResourceReference { @@ -334,7 +454,7 @@ type CreateSessionResponse struct { func (x *CreateSessionResponse) Reset() { *x = CreateSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -346,7 +466,7 @@ func (x *CreateSessionResponse) String() string { func (*CreateSessionResponse) ProtoMessage() {} func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -359,7 +479,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{4} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{6} } func (x *CreateSessionResponse) GetSession() *Session { @@ -378,7 +498,7 @@ type GetSessionRequest struct { func (x *GetSessionRequest) Reset() { *x = GetSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -390,7 +510,7 @@ func (x *GetSessionRequest) String() string { func (*GetSessionRequest) ProtoMessage() {} func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -403,7 +523,7 @@ func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. func (*GetSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{5} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{7} } func (x *GetSessionRequest) GetSessionId() string { @@ -422,7 +542,7 @@ type GetSessionResponse struct { func (x *GetSessionResponse) Reset() { *x = GetSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -434,7 +554,7 @@ func (x *GetSessionResponse) String() string { func (*GetSessionResponse) ProtoMessage() {} func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -447,7 +567,7 @@ func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. func (*GetSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{6} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{8} } func (x *GetSessionResponse) GetSession() *Session { @@ -470,7 +590,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -482,7 +602,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -495,7 +615,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{7} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{9} } func (x *ListSessionsRequest) GetAllCreators() bool { @@ -529,7 +649,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +661,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -554,7 +674,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{8} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{10} } func (x *ListSessionsResponse) GetSessions() []*Session { @@ -583,7 +703,7 @@ type UpdateSessionNameRequest struct { func (x *UpdateSessionNameRequest) Reset() { *x = UpdateSessionNameRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -595,7 +715,7 @@ func (x *UpdateSessionNameRequest) String() string { func (*UpdateSessionNameRequest) ProtoMessage() {} func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -608,7 +728,7 @@ func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameRequest.ProtoReflect.Descriptor instead. func (*UpdateSessionNameRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{9} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{11} } func (x *UpdateSessionNameRequest) GetSessionId() string { @@ -634,7 +754,7 @@ type UpdateSessionNameResponse struct { func (x *UpdateSessionNameResponse) Reset() { *x = UpdateSessionNameResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -646,7 +766,7 @@ func (x *UpdateSessionNameResponse) String() string { func (*UpdateSessionNameResponse) ProtoMessage() {} func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -659,7 +779,7 @@ func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameResponse.ProtoReflect.Descriptor instead. func (*UpdateSessionNameResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{10} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{12} } func (x *UpdateSessionNameResponse) GetSession() *Session { @@ -678,7 +798,7 @@ type RevertSessionRequest struct { func (x *RevertSessionRequest) Reset() { *x = RevertSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -690,7 +810,7 @@ func (x *RevertSessionRequest) String() string { func (*RevertSessionRequest) ProtoMessage() {} func (x *RevertSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -703,7 +823,7 @@ func (x *RevertSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertSessionRequest.ProtoReflect.Descriptor instead. func (*RevertSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{11} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{13} } func (x *RevertSessionRequest) GetSessionId() string { @@ -722,7 +842,7 @@ type RevertSessionResponse struct { func (x *RevertSessionResponse) Reset() { *x = RevertSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -734,7 +854,7 @@ func (x *RevertSessionResponse) String() string { func (*RevertSessionResponse) ProtoMessage() {} func (x *RevertSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -747,7 +867,7 @@ func (x *RevertSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertSessionResponse.ProtoReflect.Descriptor instead. func (*RevertSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{12} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{14} } func (x *RevertSessionResponse) GetSession() *Session { @@ -769,7 +889,7 @@ type DeleteSessionRequest struct { func (x *DeleteSessionRequest) Reset() { *x = DeleteSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -781,7 +901,7 @@ func (x *DeleteSessionRequest) String() string { func (*DeleteSessionRequest) ProtoMessage() {} func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -794,7 +914,7 @@ func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{13} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{15} } func (x *DeleteSessionRequest) GetSessionId() string { @@ -820,7 +940,7 @@ type DeleteSessionResponse struct { func (x *DeleteSessionResponse) Reset() { *x = DeleteSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -832,7 +952,7 @@ func (x *DeleteSessionResponse) String() string { func (*DeleteSessionResponse) ProtoMessage() {} func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -845,7 +965,7 @@ func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{14} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{16} } func (x *DeleteSessionResponse) GetSession() *Session { @@ -869,7 +989,7 @@ type SessionShare struct { func (x *SessionShare) Reset() { *x = SessionShare{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -881,7 +1001,7 @@ func (x *SessionShare) String() string { func (*SessionShare) ProtoMessage() {} func (x *SessionShare) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -894,7 +1014,7 @@ func (x *SessionShare) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionShare.ProtoReflect.Descriptor instead. func (*SessionShare) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{15} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{17} } func (x *SessionShare) GetId() string { @@ -938,7 +1058,7 @@ type CreateSessionShareRequest struct { func (x *CreateSessionShareRequest) Reset() { *x = CreateSessionShareRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -950,7 +1070,7 @@ func (x *CreateSessionShareRequest) String() string { func (*CreateSessionShareRequest) ProtoMessage() {} func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -963,7 +1083,7 @@ func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareRequest.ProtoReflect.Descriptor instead. func (*CreateSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{16} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{18} } func (x *CreateSessionShareRequest) GetSessionId() string { @@ -991,7 +1111,7 @@ type CreateSessionShareResponse struct { func (x *CreateSessionShareResponse) Reset() { *x = CreateSessionShareResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1003,7 +1123,7 @@ func (x *CreateSessionShareResponse) String() string { func (*CreateSessionShareResponse) ProtoMessage() {} func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1016,7 +1136,7 @@ func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareResponse.ProtoReflect.Descriptor instead. func (*CreateSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{17} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{19} } func (x *CreateSessionShareResponse) GetShare() *SessionShare { @@ -1043,7 +1163,7 @@ type ListSessionSharesRequest struct { func (x *ListSessionSharesRequest) Reset() { *x = ListSessionSharesRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1055,7 +1175,7 @@ func (x *ListSessionSharesRequest) String() string { func (*ListSessionSharesRequest) ProtoMessage() {} func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1068,7 +1188,7 @@ func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesRequest.ProtoReflect.Descriptor instead. func (*ListSessionSharesRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{18} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{20} } func (x *ListSessionSharesRequest) GetSessionId() string { @@ -1095,7 +1215,7 @@ type ListSessionSharesResponse struct { func (x *ListSessionSharesResponse) Reset() { *x = ListSessionSharesResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1107,7 +1227,7 @@ func (x *ListSessionSharesResponse) String() string { func (*ListSessionSharesResponse) ProtoMessage() {} func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1120,7 +1240,7 @@ func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesResponse.ProtoReflect.Descriptor instead. func (*ListSessionSharesResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{19} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{21} } func (x *ListSessionSharesResponse) GetShares() []*SessionShare { @@ -1146,7 +1266,7 @@ type RevokeSessionShareRequest struct { func (x *RevokeSessionShareRequest) Reset() { *x = RevokeSessionShareRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1158,7 +1278,7 @@ func (x *RevokeSessionShareRequest) String() string { func (*RevokeSessionShareRequest) ProtoMessage() {} func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1171,7 +1291,7 @@ func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareRequest.ProtoReflect.Descriptor instead. func (*RevokeSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{20} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{22} } func (x *RevokeSessionShareRequest) GetShareId() string { @@ -1189,7 +1309,7 @@ type RevokeSessionShareResponse struct { func (x *RevokeSessionShareResponse) Reset() { *x = RevokeSessionShareResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1201,7 +1321,7 @@ func (x *RevokeSessionShareResponse) String() string { func (*RevokeSessionShareResponse) ProtoMessage() {} func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1214,7 +1334,7 @@ func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareResponse.ProtoReflect.Descriptor instead. func (*RevokeSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{21} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{23} } var File_kagent_api_v1_sessions_proto protoreflect.FileDescriptor @@ -1224,7 +1344,7 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "\x1ckagent/api/v1/sessions.proto\x12\rkagent.api.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1akagent/api/v1/common.proto\x1a\x1bkagent/api/v1/runtime.proto\";\n" + "\aFailure\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\x88\x04\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\xc4\x04\n" + "\aSession\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\acreator\x18\x02 \x01(\tR\acreator\x124\n" + @@ -1241,7 +1361,16 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "updated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x12\n" + "\x04name\x18\r \x01(\tR\x04name\x12\x1d\n" + "\n" + - "context_id\x18\x0e \x01(\tR\tcontextId\"W\n" + + "context_id\x18\x0e \x01(\tR\tcontextId\x12:\n" + + "\brecovery\x18\x10 \x01(\v2\x1e.kagent.api.v1.SessionRecoveryR\brecovery\"\x8a\x01\n" + + "\x0fSessionRecovery\x12\x1b\n" + + "\x06needed\x18\x01 \x01(\bH\x00R\x06needed\x88\x01\x01\x12O\n" + + "\x0ffailed_messages\x18\x03 \x03(\v2&.kagent.api.v1.SessionMessageReferenceR\x0efailedMessagesB\t\n" + + "\a_needed\"Q\n" + + "\x17SessionMessageReference\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n" + + "\n" + + "message_id\x18\x02 \x01(\tR\tmessageId\"W\n" + "\x0fAgentProvenance\x122\n" + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceR\x03ref\x12\x10\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\"\xd4\x01\n" + @@ -1331,86 +1460,90 @@ func file_kagent_api_v1_sessions_proto_rawDescGZIP() []byte { return file_kagent_api_v1_sessions_proto_rawDescData } -var file_kagent_api_v1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_kagent_api_v1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_kagent_api_v1_sessions_proto_goTypes = []any{ (*Failure)(nil), // 0: kagent.api.v1.Failure (*Session)(nil), // 1: kagent.api.v1.Session - (*AgentProvenance)(nil), // 2: kagent.api.v1.AgentProvenance - (*CreateSessionRequest)(nil), // 3: kagent.api.v1.CreateSessionRequest - (*CreateSessionResponse)(nil), // 4: kagent.api.v1.CreateSessionResponse - (*GetSessionRequest)(nil), // 5: kagent.api.v1.GetSessionRequest - (*GetSessionResponse)(nil), // 6: kagent.api.v1.GetSessionResponse - (*ListSessionsRequest)(nil), // 7: kagent.api.v1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 8: kagent.api.v1.ListSessionsResponse - (*UpdateSessionNameRequest)(nil), // 9: kagent.api.v1.UpdateSessionNameRequest - (*UpdateSessionNameResponse)(nil), // 10: kagent.api.v1.UpdateSessionNameResponse - (*RevertSessionRequest)(nil), // 11: kagent.api.v1.RevertSessionRequest - (*RevertSessionResponse)(nil), // 12: kagent.api.v1.RevertSessionResponse - (*DeleteSessionRequest)(nil), // 13: kagent.api.v1.DeleteSessionRequest - (*DeleteSessionResponse)(nil), // 14: kagent.api.v1.DeleteSessionResponse - (*SessionShare)(nil), // 15: kagent.api.v1.SessionShare - (*CreateSessionShareRequest)(nil), // 16: kagent.api.v1.CreateSessionShareRequest - (*CreateSessionShareResponse)(nil), // 17: kagent.api.v1.CreateSessionShareResponse - (*ListSessionSharesRequest)(nil), // 18: kagent.api.v1.ListSessionSharesRequest - (*ListSessionSharesResponse)(nil), // 19: kagent.api.v1.ListSessionSharesResponse - (*RevokeSessionShareRequest)(nil), // 20: kagent.api.v1.RevokeSessionShareRequest - (*RevokeSessionShareResponse)(nil), // 21: kagent.api.v1.RevokeSessionShareResponse - (RuntimeState)(0), // 22: kagent.api.v1.RuntimeState - (RuntimeOperation)(0), // 23: kagent.api.v1.RuntimeOperation - (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp - (*ResourceReference)(nil), // 25: kagent.api.v1.ResourceReference - (*PageRequest)(nil), // 26: kagent.api.v1.PageRequest - (*PageResponse)(nil), // 27: kagent.api.v1.PageResponse - (*durationpb.Duration)(nil), // 28: google.protobuf.Duration + (*SessionRecovery)(nil), // 2: kagent.api.v1.SessionRecovery + (*SessionMessageReference)(nil), // 3: kagent.api.v1.SessionMessageReference + (*AgentProvenance)(nil), // 4: kagent.api.v1.AgentProvenance + (*CreateSessionRequest)(nil), // 5: kagent.api.v1.CreateSessionRequest + (*CreateSessionResponse)(nil), // 6: kagent.api.v1.CreateSessionResponse + (*GetSessionRequest)(nil), // 7: kagent.api.v1.GetSessionRequest + (*GetSessionResponse)(nil), // 8: kagent.api.v1.GetSessionResponse + (*ListSessionsRequest)(nil), // 9: kagent.api.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 10: kagent.api.v1.ListSessionsResponse + (*UpdateSessionNameRequest)(nil), // 11: kagent.api.v1.UpdateSessionNameRequest + (*UpdateSessionNameResponse)(nil), // 12: kagent.api.v1.UpdateSessionNameResponse + (*RevertSessionRequest)(nil), // 13: kagent.api.v1.RevertSessionRequest + (*RevertSessionResponse)(nil), // 14: kagent.api.v1.RevertSessionResponse + (*DeleteSessionRequest)(nil), // 15: kagent.api.v1.DeleteSessionRequest + (*DeleteSessionResponse)(nil), // 16: kagent.api.v1.DeleteSessionResponse + (*SessionShare)(nil), // 17: kagent.api.v1.SessionShare + (*CreateSessionShareRequest)(nil), // 18: kagent.api.v1.CreateSessionShareRequest + (*CreateSessionShareResponse)(nil), // 19: kagent.api.v1.CreateSessionShareResponse + (*ListSessionSharesRequest)(nil), // 20: kagent.api.v1.ListSessionSharesRequest + (*ListSessionSharesResponse)(nil), // 21: kagent.api.v1.ListSessionSharesResponse + (*RevokeSessionShareRequest)(nil), // 22: kagent.api.v1.RevokeSessionShareRequest + (*RevokeSessionShareResponse)(nil), // 23: kagent.api.v1.RevokeSessionShareResponse + (RuntimeState)(0), // 24: kagent.api.v1.RuntimeState + (RuntimeOperation)(0), // 25: kagent.api.v1.RuntimeOperation + (*timestamppb.Timestamp)(nil), // 26: google.protobuf.Timestamp + (*ResourceReference)(nil), // 27: kagent.api.v1.ResourceReference + (*PageRequest)(nil), // 28: kagent.api.v1.PageRequest + (*PageResponse)(nil), // 29: kagent.api.v1.PageResponse + (*durationpb.Duration)(nil), // 30: google.protobuf.Duration } var file_kagent_api_v1_sessions_proto_depIdxs = []int32{ - 2, // 0: kagent.api.v1.Session.agent:type_name -> kagent.api.v1.AgentProvenance - 22, // 1: kagent.api.v1.Session.state:type_name -> kagent.api.v1.RuntimeState - 23, // 2: kagent.api.v1.Session.operation:type_name -> kagent.api.v1.RuntimeOperation + 4, // 0: kagent.api.v1.Session.agent:type_name -> kagent.api.v1.AgentProvenance + 24, // 1: kagent.api.v1.Session.state:type_name -> kagent.api.v1.RuntimeState + 25, // 2: kagent.api.v1.Session.operation:type_name -> kagent.api.v1.RuntimeOperation 0, // 3: kagent.api.v1.Session.failure:type_name -> kagent.api.v1.Failure - 24, // 4: kagent.api.v1.Session.created_at:type_name -> google.protobuf.Timestamp - 24, // 5: kagent.api.v1.Session.updated_at:type_name -> google.protobuf.Timestamp - 25, // 6: kagent.api.v1.AgentProvenance.ref:type_name -> kagent.api.v1.ResourceReference - 25, // 7: kagent.api.v1.CreateSessionRequest.agent:type_name -> kagent.api.v1.ResourceReference - 1, // 8: kagent.api.v1.CreateSessionResponse.session:type_name -> kagent.api.v1.Session - 1, // 9: kagent.api.v1.GetSessionResponse.session:type_name -> kagent.api.v1.Session - 26, // 10: kagent.api.v1.ListSessionsRequest.page:type_name -> kagent.api.v1.PageRequest - 25, // 11: kagent.api.v1.ListSessionsRequest.agent:type_name -> kagent.api.v1.ResourceReference - 1, // 12: kagent.api.v1.ListSessionsResponse.sessions:type_name -> kagent.api.v1.Session - 27, // 13: kagent.api.v1.ListSessionsResponse.page:type_name -> kagent.api.v1.PageResponse - 1, // 14: kagent.api.v1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1.Session - 1, // 15: kagent.api.v1.RevertSessionResponse.session:type_name -> kagent.api.v1.Session - 1, // 16: kagent.api.v1.DeleteSessionResponse.session:type_name -> kagent.api.v1.Session - 24, // 17: kagent.api.v1.SessionShare.created_at:type_name -> google.protobuf.Timestamp - 24, // 18: kagent.api.v1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp - 28, // 19: kagent.api.v1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration - 15, // 20: kagent.api.v1.CreateSessionShareResponse.share:type_name -> kagent.api.v1.SessionShare - 26, // 21: kagent.api.v1.ListSessionSharesRequest.page:type_name -> kagent.api.v1.PageRequest - 15, // 22: kagent.api.v1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1.SessionShare - 27, // 23: kagent.api.v1.ListSessionSharesResponse.page:type_name -> kagent.api.v1.PageResponse - 3, // 24: kagent.api.v1.SessionService.CreateSession:input_type -> kagent.api.v1.CreateSessionRequest - 5, // 25: kagent.api.v1.SessionService.GetSession:input_type -> kagent.api.v1.GetSessionRequest - 7, // 26: kagent.api.v1.SessionService.ListSessions:input_type -> kagent.api.v1.ListSessionsRequest - 9, // 27: kagent.api.v1.SessionService.UpdateSessionName:input_type -> kagent.api.v1.UpdateSessionNameRequest - 11, // 28: kagent.api.v1.SessionService.RevertSession:input_type -> kagent.api.v1.RevertSessionRequest - 13, // 29: kagent.api.v1.SessionService.DeleteSession:input_type -> kagent.api.v1.DeleteSessionRequest - 16, // 30: kagent.api.v1.SessionService.CreateSessionShare:input_type -> kagent.api.v1.CreateSessionShareRequest - 18, // 31: kagent.api.v1.SessionService.ListSessionShares:input_type -> kagent.api.v1.ListSessionSharesRequest - 20, // 32: kagent.api.v1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1.RevokeSessionShareRequest - 4, // 33: kagent.api.v1.SessionService.CreateSession:output_type -> kagent.api.v1.CreateSessionResponse - 6, // 34: kagent.api.v1.SessionService.GetSession:output_type -> kagent.api.v1.GetSessionResponse - 8, // 35: kagent.api.v1.SessionService.ListSessions:output_type -> kagent.api.v1.ListSessionsResponse - 10, // 36: kagent.api.v1.SessionService.UpdateSessionName:output_type -> kagent.api.v1.UpdateSessionNameResponse - 12, // 37: kagent.api.v1.SessionService.RevertSession:output_type -> kagent.api.v1.RevertSessionResponse - 14, // 38: kagent.api.v1.SessionService.DeleteSession:output_type -> kagent.api.v1.DeleteSessionResponse - 17, // 39: kagent.api.v1.SessionService.CreateSessionShare:output_type -> kagent.api.v1.CreateSessionShareResponse - 19, // 40: kagent.api.v1.SessionService.ListSessionShares:output_type -> kagent.api.v1.ListSessionSharesResponse - 21, // 41: kagent.api.v1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1.RevokeSessionShareResponse - 33, // [33:42] is the sub-list for method output_type - 24, // [24:33] is the sub-list for method input_type - 24, // [24:24] is the sub-list for extension type_name - 24, // [24:24] is the sub-list for extension extendee - 0, // [0:24] is the sub-list for field type_name + 26, // 4: kagent.api.v1.Session.created_at:type_name -> google.protobuf.Timestamp + 26, // 5: kagent.api.v1.Session.updated_at:type_name -> google.protobuf.Timestamp + 2, // 6: kagent.api.v1.Session.recovery:type_name -> kagent.api.v1.SessionRecovery + 3, // 7: kagent.api.v1.SessionRecovery.failed_messages:type_name -> kagent.api.v1.SessionMessageReference + 27, // 8: kagent.api.v1.AgentProvenance.ref:type_name -> kagent.api.v1.ResourceReference + 27, // 9: kagent.api.v1.CreateSessionRequest.agent:type_name -> kagent.api.v1.ResourceReference + 1, // 10: kagent.api.v1.CreateSessionResponse.session:type_name -> kagent.api.v1.Session + 1, // 11: kagent.api.v1.GetSessionResponse.session:type_name -> kagent.api.v1.Session + 28, // 12: kagent.api.v1.ListSessionsRequest.page:type_name -> kagent.api.v1.PageRequest + 27, // 13: kagent.api.v1.ListSessionsRequest.agent:type_name -> kagent.api.v1.ResourceReference + 1, // 14: kagent.api.v1.ListSessionsResponse.sessions:type_name -> kagent.api.v1.Session + 29, // 15: kagent.api.v1.ListSessionsResponse.page:type_name -> kagent.api.v1.PageResponse + 1, // 16: kagent.api.v1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1.Session + 1, // 17: kagent.api.v1.RevertSessionResponse.session:type_name -> kagent.api.v1.Session + 1, // 18: kagent.api.v1.DeleteSessionResponse.session:type_name -> kagent.api.v1.Session + 26, // 19: kagent.api.v1.SessionShare.created_at:type_name -> google.protobuf.Timestamp + 26, // 20: kagent.api.v1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp + 30, // 21: kagent.api.v1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration + 17, // 22: kagent.api.v1.CreateSessionShareResponse.share:type_name -> kagent.api.v1.SessionShare + 28, // 23: kagent.api.v1.ListSessionSharesRequest.page:type_name -> kagent.api.v1.PageRequest + 17, // 24: kagent.api.v1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1.SessionShare + 29, // 25: kagent.api.v1.ListSessionSharesResponse.page:type_name -> kagent.api.v1.PageResponse + 5, // 26: kagent.api.v1.SessionService.CreateSession:input_type -> kagent.api.v1.CreateSessionRequest + 7, // 27: kagent.api.v1.SessionService.GetSession:input_type -> kagent.api.v1.GetSessionRequest + 9, // 28: kagent.api.v1.SessionService.ListSessions:input_type -> kagent.api.v1.ListSessionsRequest + 11, // 29: kagent.api.v1.SessionService.UpdateSessionName:input_type -> kagent.api.v1.UpdateSessionNameRequest + 13, // 30: kagent.api.v1.SessionService.RevertSession:input_type -> kagent.api.v1.RevertSessionRequest + 15, // 31: kagent.api.v1.SessionService.DeleteSession:input_type -> kagent.api.v1.DeleteSessionRequest + 18, // 32: kagent.api.v1.SessionService.CreateSessionShare:input_type -> kagent.api.v1.CreateSessionShareRequest + 20, // 33: kagent.api.v1.SessionService.ListSessionShares:input_type -> kagent.api.v1.ListSessionSharesRequest + 22, // 34: kagent.api.v1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1.RevokeSessionShareRequest + 6, // 35: kagent.api.v1.SessionService.CreateSession:output_type -> kagent.api.v1.CreateSessionResponse + 8, // 36: kagent.api.v1.SessionService.GetSession:output_type -> kagent.api.v1.GetSessionResponse + 10, // 37: kagent.api.v1.SessionService.ListSessions:output_type -> kagent.api.v1.ListSessionsResponse + 12, // 38: kagent.api.v1.SessionService.UpdateSessionName:output_type -> kagent.api.v1.UpdateSessionNameResponse + 14, // 39: kagent.api.v1.SessionService.RevertSession:output_type -> kagent.api.v1.RevertSessionResponse + 16, // 40: kagent.api.v1.SessionService.DeleteSession:output_type -> kagent.api.v1.DeleteSessionResponse + 19, // 41: kagent.api.v1.SessionService.CreateSessionShare:output_type -> kagent.api.v1.CreateSessionShareResponse + 21, // 42: kagent.api.v1.SessionService.ListSessionShares:output_type -> kagent.api.v1.ListSessionSharesResponse + 23, // 43: kagent.api.v1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1.RevokeSessionShareResponse + 35, // [35:44] is the sub-list for method output_type + 26, // [26:35] is the sub-list for method input_type + 26, // [26:26] is the sub-list for extension type_name + 26, // [26:26] is the sub-list for extension extendee + 0, // [0:26] is the sub-list for field type_name } func init() { file_kagent_api_v1_sessions_proto_init() } @@ -1420,13 +1553,14 @@ func file_kagent_api_v1_sessions_proto_init() { } file_kagent_api_v1_common_proto_init() file_kagent_api_v1_runtime_proto_init() + file_kagent_api_v1_sessions_proto_msgTypes[2].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1_sessions_proto_rawDesc), len(file_kagent_api_v1_sessions_proto_rawDesc)), NumEnums: 0, - NumMessages: 22, + NumMessages: 24, NumExtensions: 0, NumServices: 1, }, diff --git a/go/core/internal/database/session_recovery.go b/go/core/internal/database/session_recovery.go index d591f33b5b..9160648940 100644 --- a/go/core/internal/database/session_recovery.go +++ b/go/core/internal/database/session_recovery.go @@ -104,6 +104,9 @@ func (c *Client) BeginCrashedSessionRecovery(ctx context.Context, generation *Se if busy { return ErrConflict } + if err := recordDiscardedSessionMessages(ctx, tx, row, session, snapshot); err != nil { + return err + } row.State = apiv1.RuntimeState_RUNTIME_STATE_FAILED.String() row.runtimeInstanceRow, err = beginRuntimeOperation(ctx, tx, row.runtimeInstanceRow, runtimeKindAgent, apiv1.RuntimeOperation_RUNTIME_OPERATION_REVERT) if err != nil { @@ -128,6 +131,79 @@ func (c *Client) BeginCrashedSessionRecovery(ctx context.Context, generation *Se return operation, err } +// The Session lock keeps annotations atomic with recovery admission and task settlement. +// +// The boundary is the newest history event tagged with the snapshot the Actor +// reverts to. A runtime without a snapshot restarts from its initial state, so +// every input is discarded. A snapshot that no event records leaves the +// boundary unknown: a quiescence whose Substrate call succeeded but whose +// commit did not, or a forked Actor reporting a different URI than its copied +// boundary. Recovery then records no new annotations rather than marking the +// whole transcript lost, because most of that history is usually in the +// snapshot. Annotations from earlier reverts are kept either way. +func recordDiscardedSessionMessages(ctx context.Context, tx pgx.Tx, row sessionRow, session *apiv1.Session, snapshot SessionTaskSnapshot) error { + type messageRow struct { + TaskID string + MessageID string + Data []byte + } + // An empty snapshot URI keeps boundary 0, so every message is discarded. A + // URI that no event records yields a NULL boundary, which selects nothing. + messages, err := queryMany(ctx, tx, ` + WITH boundary AS ( + SELECT CASE WHEN $2::text = '' THEN 0 ELSE MAX(sequence) END AS sequence + FROM session_task_event + WHERE history_id = $1 AND snapshot_uri = $2 + AND snapshot_fidelity = $3 AND snapshot_atespace = $4) + SELECT e.task_id, e.message_id, e.data + FROM session_task_event e, boundary b + WHERE e.history_id = $1 AND e.message_id IS NOT NULL AND e.sequence > b.sequence + ORDER BY e.sequence + `, pgx.RowToStructByName[messageRow], row.HistoryID, snapshot.URI, snapshot.Fidelity, snapshot.Atespace) + if err != nil { + return fmt.Errorf("read discarded Session messages: %w", err) + } + if len(messages) == 0 { + return nil + } + failed := append([]*apiv1.SessionMessageReference(nil), session.GetRecovery().GetFailedMessages()...) + for _, row := range messages { + event, err := unmarshalSessionTaskEvent(row.Data) + if err != nil { + return err + } + message, ok := event.(*a2a.Message) + if !ok || message == nil || row.TaskID == "" || row.MessageID == "" || message.ID != row.MessageID || string(message.TaskID) != row.TaskID || message.ContextID != session.ContextId { + return fmt.Errorf("discarded Session message has inconsistent identity") + } + if message.Role == a2a.MessageRoleUser { + failed = append(failed, &apiv1.SessionMessageReference{TaskId: row.TaskID, MessageId: row.MessageID}) + } + } + if len(failed) == 0 { + return nil + } + type identity struct{ taskID, messageID string } + seen := make(map[identity]struct{}, len(failed)) + unique := make([]*apiv1.SessionMessageReference, 0, len(failed)) + for _, message := range failed { + if message.GetTaskId() == "" || message.GetMessageId() == "" { + return fmt.Errorf("discarded Session message requires a task and message identity") + } + key := identity{message.TaskId, message.MessageId} + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + unique = append(unique, message) + } + if session.Recovery == nil { + session.Recovery = &apiv1.SessionRecovery{} + } + session.Recovery.FailedMessages = unique + return nil +} + // settleCrashedSessionTasks publishes saved results, keeps waits the snapshot // captured, and fails the rest of the open work the revert discards. func settleCrashedSessionTasks(ctx context.Context, tx pgx.Tx, session sessionRow, snapshot SessionTaskSnapshot) error { diff --git a/go/core/internal/database/session_recovery_messages_test.go b/go/core/internal/database/session_recovery_messages_test.go new file mode 100644 index 0000000000..7a574bde2e --- /dev/null +++ b/go/core/internal/database/session_recovery_messages_test.go @@ -0,0 +1,138 @@ +package database + +import ( + "testing" + + "github.com/a2aproject/a2a-go/v2/a2a" + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" +) + +func TestCrashedRecoveryRejectsMalformedMessageHistory(t *testing.T) { + for _, test := range []struct { + name string + data []byte + emptyPayload bool + mutate func(*a2apb.Message) + }{ + {name: "invalid protobuf", data: []byte{0xff}}, + {name: "not a message", emptyPayload: true}, + {name: "message identity", mutate: func(message *a2apb.Message) { + message.MessageId = "different-message" + }}, + {name: "task identity", mutate: func(message *a2apb.Message) { + message.TaskId = "different-task" + }}, + {name: "context identity", mutate: func(message *a2apb.Message) { + message.ContextId = "different-context" + }}, + } { + t.Run(test.name, func(t *testing.T) { + client := NewClient(setupTestDB(t), "public") + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, task := waitingTaskFixture(t, client) + message := &a2apb.Message{ + MessageId: task.History[0].ID, TaskId: string(task.ID), ContextId: session.ContextId, + Role: a2apb.Role_ROLE_USER, + } + if test.mutate != nil { + test.mutate(message) + } + data := test.data + if data == nil { + event := &a2apb.StreamResponse{} + if !test.emptyPayload { + event.Payload = &a2apb.StreamResponse_Message{Message: message} + } + var err error + data, err = proto.Marshal(event) + require.NoError(t, err) + } + // Ordinary task writes reject these malformed identities before persistence. + _, err := client.db.Exec(t.Context(), ` + UPDATE session_task_event SET data = $2 + WHERE task_id = $1 AND message_id = $3 + `, string(task.ID), data, task.History[0].ID) + require.NoError(t, err) + + _, err = client.BeginCrashedSessionRecovery(t.Context(), observedGeneration(t, client, session.Id, "actor-"+session.Id), SessionTaskSnapshot{}) + require.Error(t, err) + current, err := client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, current.State) + require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, current.Operation) + require.Empty(t, current.GetRecovery().GetFailedMessages()) + require.Nil(t, current.Failure) + }) + } +} + +func TestCrashedRecoveryRollsBackAnnotationsWhenSettlementFails(t *testing.T) { + client := NewClient(setupTestDB(t), "public") + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, task := waitingTaskFixture(t, client) + _, version, err := client.GetVersionedSessionTask(t.Context(), session.Id, string(task.ID)) + require.NoError(t, err) + for _, state := range []a2a.TaskState{a2a.TaskStateWorking, a2a.TaskStateInputRequired} { + task.Status.State = state + version, err = client.UpdateSessionTask(t.Context(), session.Id, version, taskMutationHash(string(state)), task, task, "") + require.NoError(t, err) + } + require.NoError(t, client.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) + stored, err := readSession(t.Context(), client.db, session.Id) + require.NoError(t, err) + before, err := readSessionTask(t.Context(), client.db, stored.HistoryID, string(task.ID)) + require.NoError(t, err) + // Message history remains valid, so recovery first writes its annotations. + // Corrupting the task projection makes settlement fail later in that transaction. + _, err = client.db.Exec(t.Context(), `UPDATE session_task SET data = $2 WHERE history_id = $1 AND id = $3`, stored.HistoryID, []byte{0xff}, string(task.ID)) + require.NoError(t, err) + _, err = client.BeginCrashedSessionRecovery(t.Context(), observedGeneration(t, client, session.Id, "actor-"+session.Id), SessionTaskSnapshot{}) + require.Error(t, err) + current, err := client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, current.State) + require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, current.Operation) + require.Empty(t, current.GetRecovery().GetFailedMessages()) + + _, err = client.db.Exec(t.Context(), `UPDATE session_task SET data = $2 WHERE history_id = $1 AND id = $3`, stored.HistoryID, before.Data, string(task.ID)) + require.NoError(t, err) + work, err := client.ClaimSessionQuiescence(t.Context()) + require.NoError(t, err, "failed recovery retains the original pending runtime boundary") + require.Equal(t, string(task.ID), work.TaskID) +} + +// A snapshot no history event records leaves the retained boundary unknown, as +// after a quiescence whose commit was lost or in a fork whose Actor reports a +// different URI. Recovery must not then mark the whole transcript as lost. +func TestCrashedRecoveryWithUnrecordedSnapshotAddsNoAnnotations(t *testing.T) { + client := NewClient(setupTestDB(t), "public") + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, first := waitingTaskFixture(t, client) + actorUID := "actor-" + session.Id + // A runtime without a snapshot restarts from its initial state and loses every input. + completeCrashedRecovery(t, client, session.Id, actorUID) + earlier := []*apiv1.SessionMessageReference{{TaskId: string(first.ID), MessageId: first.History[0].ID}} + current, err := client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.True(t, proto.Equal(&apiv1.SessionRecovery{FailedMessages: earlier}, current.GetRecovery())) + + second := newSessionTask(uuid.NewString(), "second") + second.ContextID = session.ContextId + _, err = client.CreateRuntimeTask(t.Context(), session.Id, taskMutationHash("after the first revert"), second, "") + require.NoError(t, err) + operation, err := client.BeginCrashedSessionRecovery(t.Context(), observedGeneration(t, client, session.Id, actorUID), SessionTaskSnapshot{ + Atespace: "team-a", URI: "s3://snapshots/never-recorded", Fidelity: "SNAPSHOT_FIDELITY_VOLUMES", + }) + require.NoError(t, err, "an unrecorded snapshot does not block recovery") + require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_RESUME, operation.Instance.Operation) + finishCrashedRecovery(t, client, session.Id, operation, actorUID) + current, err = client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.True(t, proto.Equal(&apiv1.SessionRecovery{FailedMessages: earlier}, current.GetRecovery()), + "an unknown boundary keeps earlier annotations and adds none: got %v", current.GetRecovery()) +} diff --git a/proto/kagent/api/v1/sessions.proto b/proto/kagent/api/v1/sessions.proto index 93651ce5c0..386434a7c4 100644 --- a/proto/kagent/api/v1/sessions.proto +++ b/proto/kagent/api/v1/sessions.proto @@ -50,6 +50,25 @@ message Session { // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. string context_id = 14; + // Output only. Set only by GetSession; every other response leaves it unset. + SessionRecovery recovery = 16; +} + +message SessionRecovery { + // 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 needed = 1; + // User messages the restored runtime does not remember, from every revert of + // this Session. Each appears once. Their A2A history remains available, + // including completed task results, so the list lasts as long as the + // Session and grows only when a revert discards input. + repeated SessionMessageReference failed_messages = 3; +} + +// Message IDs are unique within a task, not across a conversation. +message SessionMessageReference { + string task_id = 1; + string message_id = 2; } message AgentProvenance { diff --git a/ui/src/generated/kagent/api/v1/sessions_pb.ts b/ui/src/generated/kagent/api/v1/sessions_pb.ts index fdc14c14d4..5f9e75e442 100644 --- a/ui/src/generated/kagent/api/v1/sessions_pb.ts +++ b/ui/src/generated/kagent/api/v1/sessions_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1/sessions.proto. */ export const file_kagent_api_v1_sessions: GenFile = /*@__PURE__*/ - fileDesc("ChxrYWdlbnQvYXBpL3YxL3Nlc3Npb25zLnByb3RvEg1rYWdlbnQuYXBpLnYxIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkikgMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRItCgVhZ2VudBgPIAEoCzIeLmthZ2VudC5hcGkudjEuQWdlbnRQcm92ZW5hbmNlEhkKEXByZXBhcmVkX3JldmlzaW9uGAUgASgJEhUKDWEyYV9hdXRob3JpdHkYBiABKAkSKgoFc3RhdGUYByABKA4yGy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVTdGF0ZRIyCglvcGVyYXRpb24YCCABKA4yHy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVPcGVyYXRpb24SJwoHZmFpbHVyZRgJIAEoCzIWLmthZ2VudC5hcGkudjEuRmFpbHVyZRIuCgpjcmVhdGVkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRuYW1lGA0gASgJEhIKCmNvbnRleHRfaWQYDiABKAkiTQoPQWdlbnRQcm92ZW5hbmNlEi0KA3JlZhgBIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2USCwoDdWlkGAIgASgJIrwBChRDcmVhdGVTZXNzaW9uUmVxdWVzdBI3CgVhZ2VudBgFIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2VCBrpIA8gBARIeCgpyZXF1ZXN0X2lkGAMgASgJQgq6SAdyBRABGIABEksKBG5hbWUYBCABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiQAoVQ3JlYXRlU2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24iMQoRR2V0U2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiPQoSR2V0U2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24ihgEKE0xpc3RTZXNzaW9uc1JlcXVlc3QSFAoMYWxsX2NyZWF0b3JzGAIgASgIEigKBHBhZ2UYAyABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0Ei8KBWFnZW50GAYgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZSJrChRMaXN0U2Vzc2lvbnNSZXNwb25zZRIoCghzZXNzaW9ucxgBIAMoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbhIpCgRwYWdlGAIgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2UihQEKGFVwZGF0ZVNlc3Npb25OYW1lUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARJLCgRuYW1lGAIgASgJQj26SDpyOBjIATIzXig/OiR8W15ccHtafVxwe0NjfV0oPzpbXlxwe0NjfV0qW15ccHtafVxwe0NjfV0pPykkIkQKGVVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiI0ChRSZXZlcnRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJAChVSZXZlcnRTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiJDChREZWxldGVTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARINCgVmb3JjZRgCIAEoCCJAChVEZWxldGVTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiKOAQoMU2Vzc2lvblNoYXJlEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSLgoKY3JlYXRlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKZXhwaXJlc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiawoZQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARIwCgN0dGwYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CCLpIBaoBAioAIlcKGkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEioKBXNoYXJlGAEgASgLMhsua2FnZW50LmFwaS52MS5TZXNzaW9uU2hhcmUSDQoFdG9rZW4YAiABKAkiYgoYTGlzdFNlc3Npb25TaGFyZXNSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEigKBHBhZ2UYAiABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0InMKGUxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USKwoGc2hhcmVzGAEgAygLMhsua2FnZW50LmFwaS52MS5TZXNzaW9uU2hhcmUSKQoEcGFnZRgCIAEoCzIbLmthZ2VudC5hcGkudjEuUGFnZVJlc3BvbnNlIjcKGVJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QSGgoIc2hhcmVfaWQYASABKAlCCLpIBXIDsAEBIhwKGlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlMvYGCg5TZXNzaW9uU2VydmljZRJaCg1DcmVhdGVTZXNzaW9uEiMua2FnZW50LmFwaS52MS5DcmVhdGVTZXNzaW9uUmVxdWVzdBokLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblJlc3BvbnNlElEKCkdldFNlc3Npb24SIC5rYWdlbnQuYXBpLnYxLkdldFNlc3Npb25SZXF1ZXN0GiEua2FnZW50LmFwaS52MS5HZXRTZXNzaW9uUmVzcG9uc2USVwoMTGlzdFNlc3Npb25zEiIua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvbnNSZXF1ZXN0GiMua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvbnNSZXNwb25zZRJmChFVcGRhdGVTZXNzaW9uTmFtZRInLmthZ2VudC5hcGkudjEuVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0Gigua2FnZW50LmFwaS52MS5VcGRhdGVTZXNzaW9uTmFtZVJlc3BvbnNlEloKDVJldmVydFNlc3Npb24SIy5rYWdlbnQuYXBpLnYxLlJldmVydFNlc3Npb25SZXF1ZXN0GiQua2FnZW50LmFwaS52MS5SZXZlcnRTZXNzaW9uUmVzcG9uc2USWgoNRGVsZXRlU2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuRGVsZXRlU2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLkRlbGV0ZVNlc3Npb25SZXNwb25zZRJpChJDcmVhdGVTZXNzaW9uU2hhcmUSKC5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25TaGFyZVJlcXVlc3QaKS5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEmYKEUxpc3RTZXNzaW9uU2hhcmVzEicua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvblNoYXJlc1JlcXVlc3QaKC5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USaQoSUmV2b2tlU2Vzc2lvblNoYXJlEigua2FnZW50LmFwaS52MS5SZXZva2VTZXNzaW9uU2hhcmVSZXF1ZXN0Gikua2FnZW50LmFwaS52MS5SZXZva2VTZXNzaW9uU2hhcmVSZXNwb25zZUI9WjtnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MTthcGl2MWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1_common, file_kagent_api_v1_runtime]); + fileDesc("ChxrYWdlbnQvYXBpL3YxL3Nlc3Npb25zLnByb3RvEg1rYWdlbnQuYXBpLnYxIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkixAMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRItCgVhZ2VudBgPIAEoCzIeLmthZ2VudC5hcGkudjEuQWdlbnRQcm92ZW5hbmNlEhkKEXByZXBhcmVkX3JldmlzaW9uGAUgASgJEhUKDWEyYV9hdXRob3JpdHkYBiABKAkSKgoFc3RhdGUYByABKA4yGy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVTdGF0ZRIyCglvcGVyYXRpb24YCCABKA4yHy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVPcGVyYXRpb24SJwoHZmFpbHVyZRgJIAEoCzIWLmthZ2VudC5hcGkudjEuRmFpbHVyZRIuCgpjcmVhdGVkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRuYW1lGA0gASgJEhIKCmNvbnRleHRfaWQYDiABKAkSMAoIcmVjb3ZlcnkYECABKAsyHi5rYWdlbnQuYXBpLnYxLlNlc3Npb25SZWNvdmVyeSJyCg9TZXNzaW9uUmVjb3ZlcnkSEwoGbmVlZGVkGAEgASgISACIAQESPwoPZmFpbGVkX21lc3NhZ2VzGAMgAygLMiYua2FnZW50LmFwaS52MS5TZXNzaW9uTWVzc2FnZVJlZmVyZW5jZUIJCgdfbmVlZGVkIj4KF1Nlc3Npb25NZXNzYWdlUmVmZXJlbmNlEg8KB3Rhc2tfaWQYASABKAkSEgoKbWVzc2FnZV9pZBgCIAEoCSJNCg9BZ2VudFByb3ZlbmFuY2USLQoDcmVmGAEgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZRILCgN1aWQYAiABKAkivAEKFENyZWF0ZVNlc3Npb25SZXF1ZXN0EjcKBWFnZW50GAUgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZUIGukgDyAEBEh4KCnJlcXVlc3RfaWQYAyABKAlCCrpIB3IFEAEYgAESSwoEbmFtZRgEIAEoCUI9ukg6cjgYyAEyM14oPzokfFteXHB7Wn1ccHtDY31dKD86W15ccHtDY31dKlteXHB7Wn1ccHtDY31dKT8pJCJAChVDcmVhdGVTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiIxChFHZXRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASI9ChJHZXRTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiKGAQoTTGlzdFNlc3Npb25zUmVxdWVzdBIUCgxhbGxfY3JlYXRvcnMYAiABKAgSKAoEcGFnZRgDIAEoCzIaLmthZ2VudC5hcGkudjEuUGFnZVJlcXVlc3QSLwoFYWdlbnQYBiABKAsyIC5rYWdlbnQuYXBpLnYxLlJlc291cmNlUmVmZXJlbmNlImsKFExpc3RTZXNzaW9uc1Jlc3BvbnNlEigKCHNlc3Npb25zGAEgAygLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uEikKBHBhZ2UYAiABKAsyGy5rYWdlbnQuYXBpLnYxLlBhZ2VSZXNwb25zZSKFAQoYVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEksKBG5hbWUYAiABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiRAoZVXBkYXRlU2Vzc2lvbk5hbWVSZXNwb25zZRInCgdzZXNzaW9uGAEgASgLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uIjQKFFJldmVydFNlc3Npb25SZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBIkAKFVJldmVydFNlc3Npb25SZXNwb25zZRInCgdzZXNzaW9uGAEgASgLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uIkMKFERlbGV0ZVNlc3Npb25SZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEg0KBWZvcmNlGAIgASgIIkAKFURlbGV0ZVNlc3Npb25SZXNwb25zZRInCgdzZXNzaW9uGAEgASgLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uIo4BCgxTZXNzaW9uU2hhcmUSCgoCaWQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIuCgpjcmVhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJrChlDcmVhdGVTZXNzaW9uU2hhcmVSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEjAKA3R0bBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkIIukgFqgECKgAiVwoaQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2USKgoFc2hhcmUYASABKAsyGy5rYWdlbnQuYXBpLnYxLlNlc3Npb25TaGFyZRINCgV0b2tlbhgCIAEoCSJiChhMaXN0U2Vzc2lvblNoYXJlc1JlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESKAoEcGFnZRgCIAEoCzIaLmthZ2VudC5hcGkudjEuUGFnZVJlcXVlc3QicwoZTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRIrCgZzaGFyZXMYASADKAsyGy5rYWdlbnQuYXBpLnYxLlNlc3Npb25TaGFyZRIpCgRwYWdlGAIgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2UiNwoZUmV2b2tlU2Vzc2lvblNoYXJlUmVxdWVzdBIaCghzaGFyZV9pZBgBIAEoCUIIukgFcgOwAQEiHAoaUmV2b2tlU2Vzc2lvblNoYXJlUmVzcG9uc2Uy9gYKDlNlc3Npb25TZXJ2aWNlEloKDUNyZWF0ZVNlc3Npb24SIy5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25SZXF1ZXN0GiQua2FnZW50LmFwaS52MS5DcmVhdGVTZXNzaW9uUmVzcG9uc2USUQoKR2V0U2Vzc2lvbhIgLmthZ2VudC5hcGkudjEuR2V0U2Vzc2lvblJlcXVlc3QaIS5rYWdlbnQuYXBpLnYxLkdldFNlc3Npb25SZXNwb25zZRJXCgxMaXN0U2Vzc2lvbnMSIi5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uc1JlcXVlc3QaIy5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uc1Jlc3BvbnNlEmYKEVVwZGF0ZVNlc3Npb25OYW1lEicua2FnZW50LmFwaS52MS5VcGRhdGVTZXNzaW9uTmFtZVJlcXVlc3QaKC5rYWdlbnQuYXBpLnYxLlVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USWgoNUmV2ZXJ0U2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuUmV2ZXJ0U2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLlJldmVydFNlc3Npb25SZXNwb25zZRJaCg1EZWxldGVTZXNzaW9uEiMua2FnZW50LmFwaS52MS5EZWxldGVTZXNzaW9uUmVxdWVzdBokLmthZ2VudC5hcGkudjEuRGVsZXRlU2Vzc2lvblJlc3BvbnNlEmkKEkNyZWF0ZVNlc3Npb25TaGFyZRIoLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBopLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2USZgoRTGlzdFNlc3Npb25TaGFyZXMSJy5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uU2hhcmVzUmVxdWVzdBooLmthZ2VudC5hcGkudjEuTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRJpChJSZXZva2VTZXNzaW9uU2hhcmUSKC5rYWdlbnQuYXBpLnYxLlJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QaKS5rYWdlbnQuYXBpLnYxLlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlQj1aO2dpdGh1Yi5jb20va2FnZW50LWRldi9rYWdlbnQvZ28vYXBpL2dlbi9rYWdlbnQvYXBpL3YxO2FwaXYxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1_common, file_kagent_api_v1_runtime]); /** * @generated from message kagent.api.v1.Failure @@ -111,6 +111,13 @@ export type Session = Message<"kagent.api.v1.Session"> & { * @generated from field: string context_id = 14; */ contextId: string; + + /** + * Output only. Set only by GetSession; every other response leaves it unset. + * + * @generated from field: kagent.api.v1.SessionRecovery recovery = 16; + */ + recovery?: SessionRecovery | undefined; }; /** @@ -120,6 +127,60 @@ export type Session = Message<"kagent.api.v1.Session"> & { export const SessionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_kagent_api_v1_sessions, 1); +/** + * @generated from message kagent.api.v1.SessionRecovery + */ +export type SessionRecovery = Message<"kagent.api.v1.SessionRecovery"> & { + /** + * Whether the recorded runtime crashed and needs a revert. Unset when + * GetSession could not observe the runtime; clients must not offer a revert then. + * + * @generated from field: optional bool needed = 1; + */ + needed?: boolean | undefined; + + /** + * User messages the restored runtime does not remember, from every revert of + * this Session. Each appears once. Their A2A history remains available, + * including completed task results, so the list lasts as long as the + * Session and grows only when a revert discards input. + * + * @generated from field: repeated kagent.api.v1.SessionMessageReference failed_messages = 3; + */ + failedMessages: SessionMessageReference[]; +}; + +/** + * Describes the message kagent.api.v1.SessionRecovery. + * Use `create(SessionRecoverySchema)` to create a new message. + */ +export const SessionRecoverySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 2); + +/** + * Message IDs are unique within a task, not across a conversation. + * + * @generated from message kagent.api.v1.SessionMessageReference + */ +export type SessionMessageReference = Message<"kagent.api.v1.SessionMessageReference"> & { + /** + * @generated from field: string task_id = 1; + */ + taskId: string; + + /** + * @generated from field: string message_id = 2; + */ + messageId: string; +}; + +/** + * Describes the message kagent.api.v1.SessionMessageReference. + * Use `create(SessionMessageReferenceSchema)` to create a new message. + */ +export const SessionMessageReferenceSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 3); + /** * @generated from message kagent.api.v1.AgentProvenance */ @@ -140,7 +201,7 @@ export type AgentProvenance = Message<"kagent.api.v1.AgentProvenance"> & { * Use `create(AgentProvenanceSchema)` to create a new message. */ export const AgentProvenanceSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 2); + messageDesc(file_kagent_api_v1_sessions, 4); /** * @generated from message kagent.api.v1.CreateSessionRequest @@ -169,7 +230,7 @@ export type CreateSessionRequest = Message<"kagent.api.v1.CreateSessionRequest"> * Use `create(CreateSessionRequestSchema)` to create a new message. */ export const CreateSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 3); + messageDesc(file_kagent_api_v1_sessions, 5); /** * @generated from message kagent.api.v1.CreateSessionResponse @@ -186,7 +247,7 @@ export type CreateSessionResponse = Message<"kagent.api.v1.CreateSessionResponse * Use `create(CreateSessionResponseSchema)` to create a new message. */ export const CreateSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 4); + messageDesc(file_kagent_api_v1_sessions, 6); /** * @generated from message kagent.api.v1.GetSessionRequest @@ -203,7 +264,7 @@ export type GetSessionRequest = Message<"kagent.api.v1.GetSessionRequest"> & { * Use `create(GetSessionRequestSchema)` to create a new message. */ export const GetSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 5); + messageDesc(file_kagent_api_v1_sessions, 7); /** * @generated from message kagent.api.v1.GetSessionResponse @@ -220,7 +281,7 @@ export type GetSessionResponse = Message<"kagent.api.v1.GetSessionResponse"> & { * Use `create(GetSessionResponseSchema)` to create a new message. */ export const GetSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 6); + messageDesc(file_kagent_api_v1_sessions, 8); /** * @generated from message kagent.api.v1.ListSessionsRequest @@ -251,7 +312,7 @@ export type ListSessionsRequest = Message<"kagent.api.v1.ListSessionsRequest"> & * Use `create(ListSessionsRequestSchema)` to create a new message. */ export const ListSessionsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 7); + messageDesc(file_kagent_api_v1_sessions, 9); /** * @generated from message kagent.api.v1.ListSessionsResponse @@ -273,7 +334,7 @@ export type ListSessionsResponse = Message<"kagent.api.v1.ListSessionsResponse"> * Use `create(ListSessionsResponseSchema)` to create a new message. */ export const ListSessionsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 8); + messageDesc(file_kagent_api_v1_sessions, 10); /** * @generated from message kagent.api.v1.UpdateSessionNameRequest @@ -298,7 +359,7 @@ export type UpdateSessionNameRequest = Message<"kagent.api.v1.UpdateSessionNameR * Use `create(UpdateSessionNameRequestSchema)` to create a new message. */ export const UpdateSessionNameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 9); + messageDesc(file_kagent_api_v1_sessions, 11); /** * @generated from message kagent.api.v1.UpdateSessionNameResponse @@ -315,7 +376,7 @@ export type UpdateSessionNameResponse = Message<"kagent.api.v1.UpdateSessionName * Use `create(UpdateSessionNameResponseSchema)` to create a new message. */ export const UpdateSessionNameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 10); + messageDesc(file_kagent_api_v1_sessions, 12); /** * @generated from message kagent.api.v1.RevertSessionRequest @@ -332,7 +393,7 @@ export type RevertSessionRequest = Message<"kagent.api.v1.RevertSessionRequest"> * Use `create(RevertSessionRequestSchema)` to create a new message. */ export const RevertSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 11); + messageDesc(file_kagent_api_v1_sessions, 13); /** * @generated from message kagent.api.v1.RevertSessionResponse @@ -349,7 +410,7 @@ export type RevertSessionResponse = Message<"kagent.api.v1.RevertSessionResponse * Use `create(RevertSessionResponseSchema)` to create a new message. */ export const RevertSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 12); + messageDesc(file_kagent_api_v1_sessions, 14); /** * @generated from message kagent.api.v1.DeleteSessionRequest @@ -374,7 +435,7 @@ export type DeleteSessionRequest = Message<"kagent.api.v1.DeleteSessionRequest"> * Use `create(DeleteSessionRequestSchema)` to create a new message. */ export const DeleteSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 13); + messageDesc(file_kagent_api_v1_sessions, 15); /** * @generated from message kagent.api.v1.DeleteSessionResponse @@ -391,7 +452,7 @@ export type DeleteSessionResponse = Message<"kagent.api.v1.DeleteSessionResponse * Use `create(DeleteSessionResponseSchema)` to create a new message. */ export const DeleteSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 14); + messageDesc(file_kagent_api_v1_sessions, 16); /** * Grants read-only access to a session, including A2A get, list, and subscribe. @@ -427,7 +488,7 @@ export type SessionShare = Message<"kagent.api.v1.SessionShare"> & { * Use `create(SessionShareSchema)` to create a new message. */ export const SessionShareSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 15); + messageDesc(file_kagent_api_v1_sessions, 17); /** * @generated from message kagent.api.v1.CreateSessionShareRequest @@ -453,7 +514,7 @@ export type CreateSessionShareRequest = Message<"kagent.api.v1.CreateSessionShar * Use `create(CreateSessionShareRequestSchema)` to create a new message. */ export const CreateSessionShareRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 16); + messageDesc(file_kagent_api_v1_sessions, 18); /** * @generated from message kagent.api.v1.CreateSessionShareResponse @@ -477,7 +538,7 @@ export type CreateSessionShareResponse = Message<"kagent.api.v1.CreateSessionSha * Use `create(CreateSessionShareResponseSchema)` to create a new message. */ export const CreateSessionShareResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 17); + messageDesc(file_kagent_api_v1_sessions, 19); /** * @generated from message kagent.api.v1.ListSessionSharesRequest @@ -499,7 +560,7 @@ export type ListSessionSharesRequest = Message<"kagent.api.v1.ListSessionSharesR * Use `create(ListSessionSharesRequestSchema)` to create a new message. */ export const ListSessionSharesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 18); + messageDesc(file_kagent_api_v1_sessions, 20); /** * @generated from message kagent.api.v1.ListSessionSharesResponse @@ -521,7 +582,7 @@ export type ListSessionSharesResponse = Message<"kagent.api.v1.ListSessionShares * Use `create(ListSessionSharesResponseSchema)` to create a new message. */ export const ListSessionSharesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 19); + messageDesc(file_kagent_api_v1_sessions, 21); /** * @generated from message kagent.api.v1.RevokeSessionShareRequest @@ -538,7 +599,7 @@ export type RevokeSessionShareRequest = Message<"kagent.api.v1.RevokeSessionShar * Use `create(RevokeSessionShareRequestSchema)` to create a new message. */ export const RevokeSessionShareRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 20); + messageDesc(file_kagent_api_v1_sessions, 22); /** * @generated from message kagent.api.v1.RevokeSessionShareResponse @@ -551,7 +612,7 @@ export type RevokeSessionShareResponse = Message<"kagent.api.v1.RevokeSessionSha * Use `create(RevokeSessionShareResponseSchema)` to create a new message. */ export const RevokeSessionShareResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 21); + messageDesc(file_kagent_api_v1_sessions, 23); /** * Lifecycle mutations execute inline. After a retryable error, repeat the same From 45412594df85efbfe791d1d731e8e9a336bcfe4a Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Wed, 4 Nov 2026 15:10:43 -0800 Subject: [PATCH 02/12] feat(session): report crash recovery in GetSession `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 Co-Authored-By: Claude Opus 5.5 --- docs/architecture/runtime-and-lifecycle.md | 17 ++ docs/lifecycle-retries.md | 8 +- .../internal/grpcserver/scheduledrun_test.go | 2 +- go/core/internal/grpcserver/session.go | 2 +- .../grpcserver/session_recovery_test.go | 23 +++ go/core/internal/grpcserver/session_test.go | 30 +++- .../internal/grpcserver/task_store_test.go | 2 +- go/core/internal/mcp/tasks_test.go | 6 + go/core/internal/service/session/recovery.go | 34 +++- .../service/session/recovery_messages_test.go | 147 +++++++++++++++++ .../session/recovery_observation_test.go | 152 ++++++++++++++++++ go/core/internal/service/session/service.go | 45 +++++- .../internal/service/session/service_test.go | 12 +- 13 files changed, 467 insertions(+), 13 deletions(-) create mode 100644 go/core/internal/service/session/recovery_messages_test.go create mode 100644 go/core/internal/service/session/recovery_observation_test.go diff --git a/docs/architecture/runtime-and-lifecycle.md b/docs/architecture/runtime-and-lifecycle.md index a76cc94697..60e6f7bb19 100644 --- a/docs/architecture/runtime-and-lifecycle.md +++ b/docs/architecture/runtime-and-lifecycle.md @@ -284,6 +284,23 @@ The next authorized interaction resumes it through Substrate ingress. Runtime changes after the retained snapshot are lost; the discarded turn receives no checkpoint at that older snapshot. Another revert returns the Session. +`GetSession` reports a read-only recovery observation for the recorded Actor. +It is the only Session read that calls Substrate; MCP and the other Session +responses read the stored record and omit `recovery`. An unavailable or +replaced Actor leaves the observation unknown and does not prevent history +reads. The observation and `RevertSession` share one crash check, so chat offers +a revert exactly when the revert would be admitted. Chat offers that revert and +the existing fork action for a saved READY checkpoint. + +Recovery records exact task and message identities for user inputs after the +retained snapshot boundary, including accepted inputs not yet published. Chat +marks those inputs "Not remembered after recovery" and retains the public +transcript; completed A2A tasks and their history are not rewound, so a reply +to a marked input can still appear. The annotations remain on the recovered +Session. A revert to a snapshot that no history event records leaves the +boundary unknown and adds no annotations. Forks copy checkpoint-bounded history +into a fresh Session and do not inherit its delivery annotations. + ```mermaid sequenceDiagram participant Client diff --git a/docs/lifecycle-retries.md b/docs/lifecycle-retries.md index 452b09ad1b..9930daa855 100644 --- a/docs/lifecycle-retries.md +++ b/docs/lifecycle-retries.md @@ -41,12 +41,18 @@ should likewise retain the `request_id` passed to `create_sandbox`. ## Recovering a crashed Session -Call `SessionService.RevertSession` with the Session ID to restore its recorded runtime to the last durable snapshot. +Chat offers a revert or a fork from an existing READY checkpoint after a confirmed +runtime crash. Call `SessionService.RevertSession` with the Session ID to restore +its recorded runtime to the last durable snapshot. The caller needs Session update access. The operation verifies Substrate's CRASHED state and the recorded Actor identity. Recovery fails interrupted turns and waiting tasks absent from the retained snapshot. It preserves durable terminal results and returns the Session to READY with its Actor suspended. It discards runtime changes since that snapshot and does not replay input. A runtime without a snapshot restarts from its initial state. +`GetSession.recovery.failed_messages` identifies user inputs absent from the +restored runtime by both task and message ID. Their public history remains +available, and chat marks them "Not remembered after recovery". Only +`GetSession` returns `recovery`. Retry an interrupted revert against the same Session ID; nothing retries it in the background. Pending recovery retains its operation across API restarts, and a retry joins it. diff --git a/go/core/internal/grpcserver/scheduledrun_test.go b/go/core/internal/grpcserver/scheduledrun_test.go index 1d26566933..7c65949ed3 100644 --- a/go/core/internal/grpcserver/scheduledrun_test.go +++ b/go/core/internal/grpcserver/scheduledrun_test.go @@ -200,7 +200,7 @@ func scheduledRunTestServer(t *testing.T) (*database.Client, apiv1.ScheduledRunS Listener: listener, Authenticator: &authimpl.InsecureAuthenticator{}, SystemService: testSystemService(), ScheduledRunService: scheduledrun.NewService(store, kube, &pkgauth.NoopAuthorizer{}), - SessionService: sessionsvc.NewService(store, &pkgauth.NoopAuthorizer{}, nil), + SessionService: sessionsvc.NewService(store, &pkgauth.NoopAuthorizer{}, storedRecoveryWorkflow{}), }) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) diff --git a/go/core/internal/grpcserver/session.go b/go/core/internal/grpcserver/session.go index ed64db9edc..93fc1dc8e2 100644 --- a/go/core/internal/grpcserver/session.go +++ b/go/core/internal/grpcserver/session.go @@ -21,7 +21,7 @@ func (s *sessionServer) CreateSession(ctx context.Context, request *apiv1.Create } func (s *sessionServer) GetSession(ctx context.Context, request *apiv1.GetSessionRequest) (*apiv1.GetSessionResponse, error) { - session, err := s.service.Get(ctx, request.GetSessionId()) + session, err := s.service.Observe(ctx, request.GetSessionId()) if err != nil { return nil, err } diff --git a/go/core/internal/grpcserver/session_recovery_test.go b/go/core/internal/grpcserver/session_recovery_test.go index 5971703f9b..a69e3ff128 100644 --- a/go/core/internal/grpcserver/session_recovery_test.go +++ b/go/core/internal/grpcserver/session_recovery_test.go @@ -129,6 +129,14 @@ func TestRevertSessionThroughGRPC(t *testing.T) { require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) } } + observeRecovery := func(needed bool) *apiv1.Session { + t.Helper() + response, err := client.GetSession(owner, &apiv1.GetSessionRequest{SessionId: session.Id}) + require.NoError(t, err) + require.Equal(t, new(needed), response.Session.GetRecovery().Needed) + return response.Session + } + require.Empty(t, observeRecovery(false).GetRecovery().FailedMessages) request := &apiv1.RevertSessionRequest{SessionId: session.Id} if test.noSnapshot { control.actors[name].Status.Snapshots = nil @@ -146,6 +154,11 @@ func TestRevertSessionThroughGRPC(t *testing.T) { require.NoError(t, err, "a healthy runtime has nothing to recover") require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, healthy.Session.State) control.actors[name].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED + crashed := observeRecovery(true) + require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, crashed.State) + require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, crashed.Operation) + require.Nil(t, crashed.Failure) + require.Empty(t, crashed.GetRecovery().FailedMessages) before := proto.CloneOf(control.actors[name]) control.actors[name].Metadata.Uid = "replacement-uid" _, err = client.RevertSession(owner, request) @@ -173,10 +186,20 @@ func TestRevertSessionThroughGRPC(t *testing.T) { failed, err := client.GetSession(owner, &apiv1.GetSessionRequest{SessionId: session.Id}) require.NoError(t, err) require.Equal(t, database.SessionRuntimeCrashed, failed.Session.Failure.Reason) + require.Equal(t, new(true), failed.Session.GetRecovery().Needed) + // No history event records "s3://snapshots/last-good", so only a + // runtime without a snapshot has a known boundary: its initial state. + var wantFailed []*apiv1.SessionMessageReference + if task != nil && test.noSnapshot { + wantFailed = []*apiv1.SessionMessageReference{{TaskId: string(task.ID), MessageId: task.History[0].ID}} + } + require.Equal(t, wantFailed, failed.Session.GetRecovery().FailedMessages) ready, err := client.RevertSession(owner, request) require.NoError(t, err) require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, ready.Session.State) require.Nil(t, ready.Session.Failure) + require.Nil(t, ready.Session.Recovery, "only GetSession returns recovery") + require.Equal(t, wantFailed, observeRecovery(false).GetRecovery().FailedMessages) // Retry as a client whose success response was lost. retried, err := client.RevertSession(owner, request) require.NoError(t, err) diff --git a/go/core/internal/grpcserver/session_test.go b/go/core/internal/grpcserver/session_test.go index 14e426128e..a3ce536631 100644 --- a/go/core/internal/grpcserver/session_test.go +++ b/go/core/internal/grpcserver/session_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/core/internal/database" @@ -93,7 +94,7 @@ func TestSessionPermissionsAndOwnershipThroughGeneratedClient(t *testing.T) { {Id: bobID, Creator: "bob", Agent: &apiv1.AgentProvenance{Ref: agent}}, }} connection := newConfigurationConnection(t, nil, func(config *Config) { - config.SessionService = sessionsvc.NewService(store, sessionAuthorizationPolicy{}, nil) + config.SessionService = sessionsvc.NewService(store, sessionAuthorizationPolicy{}, storedRecoveryWorkflow{}) config.ShareStore = &testShareStore{sessionShare: &apiv1.SessionShare{SessionId: aliceID}, ownerUserID: "alice"} }) client := apiv1.NewSessionServiceClient(connection) @@ -207,3 +208,30 @@ func TestSessionCreateNamespaceThroughGeneratedClient(t *testing.T) { }) } } + +// storedRecoveryWorkflow stands in for the Actor workflow in transport tests +// that never reach Substrate. GetSession reports stored recovery annotations +// with needed unknown; lifecycle calls fail, because no test may reach them. +type storedRecoveryWorkflow struct{} + +var errNoRuntimeWorkflow = errors.New("transport test has no runtime workflow") + +func (storedRecoveryWorkflow) Create(context.Context, *apiv1.Session) (*apiv1.Session, error) { + return nil, errNoRuntimeWorkflow +} + +func (storedRecoveryWorkflow) Revert(context.Context, *apiv1.Session) (*apiv1.Session, error) { + return nil, errNoRuntimeWorkflow +} + +func (storedRecoveryWorkflow) Delete(context.Context, *apiv1.Session, bool) (*apiv1.Session, error) { + return nil, errNoRuntimeWorkflow +} + +func (storedRecoveryWorkflow) Recovery(_ context.Context, session *apiv1.Session) (*apiv1.SessionRecovery, error) { + recovery := proto.CloneOf(session.GetRecovery()) + if recovery != nil { + recovery.Needed = nil + } + return recovery, nil +} diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index 24082a062d..fd73f2b245 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -151,7 +151,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { Authenticator: runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL), TaskStoreService: tasks, MemoryService: memoryservice.NewService(memories, policy), - SessionService: sessionsvc.NewService(store, policy, nil), + SessionService: sessionsvc.NewService(store, policy, storedRecoveryWorkflow{}), }) require.NoError(t, err) serverCtx, stopServer := context.WithCancel(t.Context()) diff --git a/go/core/internal/mcp/tasks_test.go b/go/core/internal/mcp/tasks_test.go index ad562ee138..bac157d5e6 100644 --- a/go/core/internal/mcp/tasks_test.go +++ b/go/core/internal/mcp/tasks_test.go @@ -564,6 +564,12 @@ func (*fakeSessionStore) DeleteSessionShare(context.Context, string, string) err type fakeSessionWorkflow struct{} +// Recovery panics because MCP resolves a Session before every invoke and task +// call; observing the Actor there would add a Substrate round trip to each one. +func (*fakeSessionWorkflow) Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) { + panic("MCP must not observe Session recovery") +} + func (*fakeSessionWorkflow) Create(_ context.Context, session *apiv1.Session) (*apiv1.Session, error) { return session, nil } diff --git a/go/core/internal/service/session/recovery.go b/go/core/internal/service/session/recovery.go index 7d348775bf..39aa8a9af2 100644 --- a/go/core/internal/service/session/recovery.go +++ b/go/core/internal/service/session/recovery.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "time" ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/core/internal/database" @@ -18,6 +20,35 @@ import ( // Substrate and database failures must not wrap it, so they stay retryable. var errRuntimeUnrecoverable = fmt.Errorf("runtime cannot be recovered: %w", database.ErrFailedPrecondition) +// Recovery reports whether the Session's runtime needs a revert, from the same +// checks Revert admits one by. A failed observation leaves needed unknown, so a +// control-plane outage can neither offer recovery nor prevent history reads. +func (w *ActorWorkflow) Recovery(ctx context.Context, session *apiv1.Session) (*apiv1.SessionRecovery, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + recovery := proto.CloneOf(session.GetRecovery()) + if recovery == nil { + recovery = &apiv1.SessionRecovery{} + } + recovery.Needed = nil + if database.CrashRecoveryAdmitted(session) { + recovery.Needed = new(true) + return recovery, nil + } + if !lifecycleIdle(session) { + recovery.Needed = new(false) + return recovery, nil + } + observed, err := w.observeCrash(ctx, session) + if err != nil { + return recovery, fmt.Errorf("observe Session recovery: %w", err) + } + recovery.Needed = new(observed.crashed()) + return recovery, nil +} + +// lifecycleIdle reports a Session with no lifecycle operation in flight. Only an +// idle Session can admit a new revert; any other operation owns the Actor. func lifecycleIdle(session *apiv1.Session) bool { return session.State == apiv1.RuntimeState_RUNTIME_STATE_READY && session.Operation == apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE } @@ -32,7 +63,8 @@ func (o crashObservation) crashed() bool { } // observeCrash treats a missing, replaced, or retemplated Actor as unrecoverable, -// never as a crash: reverting it would restore the wrong runtime. +// never as a crash: reverting it would restore the wrong runtime. Recovery and Revert +// share it, so chat offers a revert exactly when Revert would admit one. // // The generation is read before the Actor. A concurrent revert keeps the Actor UID // and returns to READY, so only the generation shows that an observation predates diff --git a/go/core/internal/service/session/recovery_messages_test.go b/go/core/internal/service/session/recovery_messages_test.go new file mode 100644 index 0000000000..3a9f8bc0f5 --- /dev/null +++ b/go/core/internal/service/session/recovery_messages_test.go @@ -0,0 +1,147 @@ +package session + +import ( + "crypto/sha256" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" +) + +func TestCrashedRecoveryMarksOnlyDiscardedMessagesInOneTask(t *testing.T) { + store, actors, session, task, version := crashedTurnFixture(t) + task.Status.State = a2a.TaskStateInputRequired + hash := sha256.Sum256([]byte("captured question")) + version, err := store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], task, task, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) + captureRecoveryMessageBoundary(t, store, actors, session) + + answer := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("continue")) + answer.TaskID, answer.ContextID = task.ID, session.ContextId + reply := a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("done")) + reply.TaskID, reply.ContextID = task.ID, session.ContextId + // Repeated history must not move a captured input past the snapshot boundary. + task.History = append(task.History, answer, reply) + task.Status.State = a2a.TaskStateCompleted + hash = sha256.Sum256([]byte("unpublished completed answer")) + _, err = store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], task, task, "") + require.NoError(t, err) + before, _, err := store.GetVersionedSessionTask(t.Context(), session.Id, string(task.ID)) + require.NoError(t, err) + + recovered, err := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute).Revert(t.Context(), session) + require.NoError(t, err) + require.Equal(t, [][2]string{{string(task.ID), answer.ID}}, recoveryMessageIdentities(recovered)) + stored, err := store.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateCompleted, stored.Status.State) + require.Equal(t, before.Status, stored.Status) + require.Equal(t, before.History, stored.History, "publishing recovery retains the completed turn's full history") + require.Equal(t, before.Artifacts, stored.Artifacts) + current, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, recoveryMessageIdentities(recovered), recoveryMessageIdentities(current), "delivery failures survive a fresh Session read") +} + +func TestCrashedRecoveryScopesMessageFailuresToTheirTask(t *testing.T) { + store, actors, session, retained, version := crashedTurnFixture(t) + retained.Status.State = a2a.TaskStateCompleted + hash := sha256.Sum256([]byte("retained completed turn")) + version, err := store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], retained, retained, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(retained.ID), version)) + captureRecoveryMessageBoundary(t, store, actors, session) + beforeRetained, err := store.GetSettledSessionTask(t.Context(), session.Id, string(retained.ID), nil) + require.NoError(t, err) + + message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("a different turn")) + message.ID, message.ContextID = retained.History[0].ID, session.ContextId + discarded := a2a.NewSubmittedTask(message, message) + discarded.Status.State = a2a.TaskStateCompleted + hash = sha256.Sum256([]byte("completed without a newer snapshot")) + version, err = store.CreateRuntimeTask(t.Context(), session.Id, hash[:], discarded, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(discarded.ID), version)) + beforeDiscarded, err := store.GetSettledSessionTask(t.Context(), session.Id, string(discarded.ID), nil) + require.NoError(t, err) + + workflow := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute) + recovered, err := workflow.Revert(t.Context(), session) + require.NoError(t, err) + want := [][2]string{{string(discarded.ID), message.ID}} + require.Equal(t, want, recoveryMessageIdentities(recovered)) + for _, before := range []*a2a.Task{beforeRetained, beforeDiscarded} { + after, err := store.GetSettledSessionTask(t.Context(), session.Id, string(before.ID), nil) + require.NoError(t, err) + require.Equal(t, before, after, "recovery does not rewrite completed A2A tasks or their history") + } + + // Reverting the same runtime interval must not duplicate delivery failures. + actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED + recovered, err = workflow.Revert(t.Context(), recovered) + require.NoError(t, err) + require.Equal(t, want, recoveryMessageIdentities(recovered)) + + message = a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("a retained new turn")) + message.ContextID = session.ContextId + later := a2a.NewSubmittedTask(message, message) + later.Status.State = a2a.TaskStateCompleted + hash = sha256.Sum256([]byte("completed after recovery")) + version, err = store.CreateRuntimeTask(t.Context(), session.Id, hash[:], later, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(later.ID), version)) + captureRecoveryMessageBoundary(t, store, actors, session) + + message = a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("another lost input")) + message.ContextID = session.ContextId + lost := a2a.NewSubmittedTask(message, message) + hash = sha256.Sum256([]byte("new interrupted turn")) + _, err = store.CreateRuntimeTask(t.Context(), session.Id, hash[:], lost, "") + require.NoError(t, err) + actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED + recovered, err = workflow.Revert(t.Context(), recovered) + require.NoError(t, err) + want = append(want, [2]string{string(lost.ID), message.ID}) + require.Equal(t, want, recoveryMessageIdentities(recovered), "a newer rollback boundary does not erase earlier delivery failures") +} + +func TestCrashedRecoveryWithoutSnapshotMarksAllInputs(t *testing.T) { + store, actors, session, task, _ := crashedTurnFixture(t) + recovered, err := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute).Revert(t.Context(), session) + require.NoError(t, err) + require.Equal(t, [][2]string{{string(task.ID), task.History[0].ID}}, recoveryMessageIdentities(recovered)) +} + +func captureRecoveryMessageBoundary(t *testing.T, store *lifecycleTestStore, actors *lifecycleTestActors, session *apiv1.Session) { + t.Helper() + work, err := store.ClaimSessionQuiescence(t.Context()) + require.NoError(t, err) + require.Equal(t, session.Id, work.Session.Id) + snapshotURI := "s3://snapshots/" + uuid.NewString() + require.NoError(t, store.FinishSessionQuiescence(t.Context(), work, &database.SessionTaskSnapshot{ + Atespace: "team-a", URI: snapshotURI, Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_VOLUMES.String(), + })) + actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.Snapshots = []*ateapipb.Snapshot{{ + Generation: 1, Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_VOLUMES, + DurableSnapshot: &ateapipb.SnapshotStorage{ + State: ateapipb.SnapshotStorageState_SNAPSHOT_STORAGE_STATE_COMPLETED, + Object: &ateapipb.ObjectSnapshot{SnapshotUri: snapshotURI}, + }, + }} +} + +func recoveryMessageIdentities(session *apiv1.Session) [][2]string { + result := make([][2]string, 0, len(session.GetRecovery().GetFailedMessages())) + for _, message := range session.GetRecovery().GetFailedMessages() { + result = append(result, [2]string{message.TaskId, message.MessageId}) + } + return result +} diff --git a/go/core/internal/service/session/recovery_observation_test.go b/go/core/internal/service/session/recovery_observation_test.go new file mode 100644 index 0000000000..24df9753fc --- /dev/null +++ b/go/core/internal/service/session/recovery_observation_test.go @@ -0,0 +1,152 @@ +package session + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" +) + +func TestSessionRecoveryObservation(t *testing.T) { + for _, test := range []struct { + name string + crashed, replaced, missing bool + readErr error + want *bool + }{ + {name: "healthy", want: new(false)}, + {name: "crashed", crashed: true, want: new(true)}, + {name: "missing", missing: true}, + {name: "replaced", crashed: true, replaced: true}, + {name: "unavailable", readErr: status.Error(codes.Unavailable, "control plane unavailable")}, + } { + t.Run(test.name, func(t *testing.T) { + store, session := lifecycleFixture(t) + base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + actors := &recoveryTestActors{lifecycleTestActors: base, revert: base.RevertActor} + workflow := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + key := actorKey("team-a", substrate.ActorName(session.Id)) + if test.crashed { + base.actors[key].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED + } + if test.replaced { + base.actors[key].Metadata.Uid = "replacement" + } + if test.missing { + delete(base.actors, key) + } + actors.readErr = test.readErr + session.Recovery = &apiv1.SessionRecovery{FailedMessages: []*apiv1.SessionMessageReference{{TaskId: "task", MessageId: "message"}}} + before := proto.CloneOf(session) + observed, err := workflow.Recovery(t.Context(), session) + if test.want == nil { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, test.want, observed.Needed) + require.Equal(t, session.Recovery.FailedMessages, observed.FailedMessages) + require.True(t, proto.Equal(before, session), "observing recovery must not mutate the Session or its history") + stored, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, stored.State) + require.Nil(t, stored.Recovery, "reads do not admit recovery or persist observations") + + // What chat offers is what Revert does: needed admits a revert, + // healthy refuses one, and an unknown observation admits nothing. + // A missing or replaced Actor is refused for good, so a client stops + // retrying; only a failed read stays retryable. + _, err = workflow.Revert(t.Context(), session) + switch { + case test.missing || test.replaced: + require.ErrorIs(t, err, errRuntimeUnrecoverable) + case test.want == nil: + require.Error(t, err) + require.NotErrorIs(t, err, database.ErrFailedPrecondition) + case *test.want: + require.NoError(t, err) + default: + require.NoError(t, err, "a healthy runtime has nothing to recover") + } + }) + } +} + +func TestSessionObservePreservesHistoryWhenRecoveryObservationFails(t *testing.T) { + id := uuid.NewString() + session := &apiv1.Session{Id: id, State: apiv1.RuntimeState_RUNTIME_STATE_READY} + observation := &apiv1.SessionRecovery{FailedMessages: []*apiv1.SessionMessageReference{{TaskId: "task", MessageId: "message"}}} + service := NewService(&serviceTestStore{getResult: session}, serviceTestAuthorizer{}, serviceTestWorkflow{ + recovery: observation, recoveryErr: status.Error(codes.Unavailable, "control plane unavailable"), + }) + got, err := service.Observe(serviceTestContext("alice"), id) + require.NoError(t, err) + require.Equal(t, id, got.Id) + require.Equal(t, observation.FailedMessages, got.Recovery.FailedMessages) + require.Nil(t, got.Recovery.Needed, "an unavailable Actor must not be reported healthy") + require.Nil(t, session.Recovery, "the stored record remains unchanged") +} + +// panicRecoveryWorkflow fails a test that observes Substrate outside GetSession. +type panicRecoveryWorkflow struct{ serviceTestWorkflow } + +func (panicRecoveryWorkflow) Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) { + panic("only GetSession observes recovery") +} + +// Only GetSession observes Substrate or returns recovery. MCP resolves every +// invoke through Get, and the annotations grow with each lossy revert, so any +// other response carrying them would cost a control-plane round trip or ship +// every conversation's annotations in a list. +func TestSessionRecoveryOnlyInGetSession(t *testing.T) { + id := uuid.NewString() + annotations := []*apiv1.SessionMessageReference{{TaskId: "task", MessageId: "message"}} + stored := func() *apiv1.Session { + return &apiv1.Session{ + Id: id, State: apiv1.RuntimeState_RUNTIME_STATE_READY, + Recovery: &apiv1.SessionRecovery{FailedMessages: annotations}, + } + } + ctx := serviceTestContext("alice") + store := &serviceTestStore{getResult: stored(), sessions: []*apiv1.Session{stored()}} + service := NewService(store, serviceTestAuthorizer{}, panicRecoveryWorkflow{}) + + got, err := service.Get(ctx, id) + require.NoError(t, err) + require.Equal(t, annotations, got.GetRecovery().GetFailedMessages(), "Get serves the stored record unchanged") + + listed, err := service.List(ctx, ListRequest{}) + require.NoError(t, err) + require.Len(t, listed.Sessions, 1) + require.Nil(t, listed.Sessions[0].Recovery) + require.NotNil(t, store.sessions[0].Recovery, "omitting recovery does not mutate the stored record") + + reverted, err := service.Revert(ctx, id) + require.NoError(t, err) + require.Nil(t, reverted.Recovery) + deleted, err := service.Delete(ctx, id, false) + require.NoError(t, err) + require.Nil(t, deleted.Recovery) + + // Observing reports the workflow's result without changing the stored record. + store.getResult = stored() + store.getResult.Recovery.Needed = new(true) + observation := &apiv1.SessionRecovery{FailedMessages: annotations, Needed: new(false)} + observed, err := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{recovery: observation}).Observe(ctx, id) + require.NoError(t, err) + require.Equal(t, new(false), observed.Recovery.Needed) + require.Equal(t, annotations, observed.Recovery.FailedMessages) + require.Equal(t, new(true), store.getResult.Recovery.Needed, "observing does not mutate the stored record") +} diff --git a/go/core/internal/service/session/service.go b/go/core/internal/service/session/service.go index bb2578a4de..8eddd2d6c0 100644 --- a/go/core/internal/service/session/service.go +++ b/go/core/internal/service/session/service.go @@ -19,6 +19,7 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/kagent-dev/kagent/go/pkg/logging" ) const ( @@ -40,6 +41,7 @@ type store interface { type sessionWorkflow interface { Create(context.Context, *apiv1.Session) (*apiv1.Session, error) Revert(context.Context, *apiv1.Session) (*apiv1.Session, error) + Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) Delete(context.Context, *apiv1.Session, bool) (*apiv1.Session, error) } @@ -139,10 +141,43 @@ func (s *Service) Create(ctx context.Context, agent *apiv1.ResourceReference, re return session, nil } +// Get reads an authorized Session as stored. MCP resolves a Session's Agent +// through it before every invoke and task call, so it must not wait on Substrate. func (s *Service) Get(ctx context.Context, id string) (*apiv1.Session, error) { return s.getAuthorized(ctx, id, auth.VerbGet) } +// Observe reads an authorized Session with its recovery observation. It is the +// only Session read that calls Substrate, and GetSession is its only caller: a +// slow control plane then delays opening one conversation, not every MCP call. +// A failed observation leaves needed unset rather than failing the read, so an +// unavailable control plane cannot hide the history it would let a user recover. +func (s *Service) Observe(ctx context.Context, id string) (*apiv1.Session, error) { + session, err := s.getAuthorized(ctx, id, auth.VerbGet) + if err != nil { + return nil, err + } + session = proto.CloneOf(session) + session.Recovery, err = s.workflow.Recovery(ctx, session) + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "observe session recovery", "session_id", session.Id, "error", err) + } + return session, nil +} + +// withoutRecovery drops recovery from every response but GetSession. The +// annotations live in the Session record and grow with each revert that +// discards input, so a list response would otherwise carry them for every +// conversation, and every other response would report needed as unknown. +func withoutRecovery(session *apiv1.Session) *apiv1.Session { + if session.GetRecovery() == nil { + return session + } + session = proto.CloneOf(session) + session.Recovery = nil + return session +} + // getAuthorized loads a Session authorized for the requested operation. Reads are // independent of runtime readiness: viewing a suspended conversation must not // provision a worker. Lifecycle and A2A operations use this same access policy. @@ -193,7 +228,7 @@ func (s *Service) Rename(ctx context.Context, id, name string) (*apiv1.Session, if err != nil { return nil, serviceerrors.NewInternal("Failed to rename Session", err) } - return session, nil + return withoutRecovery(session), nil } func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, error) { @@ -217,7 +252,7 @@ func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, er result := ListResult{Sessions: []*apiv1.Session{}} if session.Id > afterID && (request.Agent == nil || proto.Equal(session.GetAgent().GetRef(), request.Agent)) { - result.Sessions = append(result.Sessions, session) + result.Sessions = append(result.Sessions, withoutRecovery(session)) } return result, nil } @@ -250,7 +285,7 @@ func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, er } return ListResult{}, err } - result.Sessions = append(result.Sessions, session) + result.Sessions = append(result.Sessions, withoutRecovery(session)) if len(result.Sessions) > pageSize { result.Sessions = result.Sessions[:pageSize] result.NextPageToken = encodePageToken(result.Sessions[pageSize-1].Id) @@ -278,7 +313,7 @@ func (s *Service) Delete(ctx context.Context, id string, force bool) (*apiv1.Ses if err != nil { return nil, serviceerrors.NewUnavailable("Failed to delete Session", err) } - return session, nil + return withoutRecovery(session), nil } func (s *Service) Revert(ctx context.Context, id string) (*apiv1.Session, error) { @@ -296,7 +331,7 @@ func (s *Service) Revert(ctx context.Context, id string) (*apiv1.Session, error) if err != nil { return nil, serviceerrors.NewUnavailable("Failed to revert Session", err) } - return session, nil + return withoutRecovery(session), nil } // CreateShare mints a read-only share of the caller's session. A positive ttl bounds how long its diff --git a/go/core/internal/service/session/service_test.go b/go/core/internal/service/session/service_test.go index 8e312517f1..912fbc00ee 100644 --- a/go/core/internal/service/session/service_test.go +++ b/go/core/internal/service/session/service_test.go @@ -123,7 +123,15 @@ func (s *serviceTestStore) DeleteSessionShare(_ context.Context, id, _ string) e return nil } -type serviceTestWorkflow struct{ err error } +type serviceTestWorkflow struct { + err error + recovery *apiv1.SessionRecovery + recoveryErr error +} + +func (w serviceTestWorkflow) Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) { + return w.recovery, w.recoveryErr +} func (w serviceTestWorkflow) Create(_ context.Context, session *apiv1.Session) (*apiv1.Session, error) { return session, w.err @@ -522,7 +530,7 @@ func TestServiceGetScopesReadsToAuthorizedIdentity(t *testing.T) { stored := &apiv1.Session{Id: id, State: apiv1.RuntimeState_RUNTIME_STATE_SUSPENDED} store := &serviceTestStore{getResult: stored} authorizer := &recordingAuthorizer{denied: map[string]bool{id: test.denied}} - // A suspended read must never touch a workflow. + // Get never touches a workflow: MCP resolves every invoke through it. service := NewService(store, authorizer, nil) result, err := service.Get(ctx, id) if test.code != "" { From 870103c78c068a7073bed98ad07495af387616dc Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Wed, 4 Nov 2026 15:17:11 -0800 Subject: [PATCH 03/12] feat(ui): read Session recovery and revert Sessions 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 Co-Authored-By: Claude Opus 5.5 --- ui/src/api/client.ts | 4 ++++ ui/src/api/domain/agentInstances.ts | 12 ++++++++++ ui/src/api/grpc/operations.ts | 20 +++++++++++++++++ ui/src/api/operations.test.ts | 34 +++++++++++++++++++++++++++++ ui/src/api/operations.ts | 1 + ui/src/mocks/mockBackend.test.ts | 3 +++ ui/src/mocks/transport.ts | 7 ++++++ 7 files changed, 81 insertions(+) diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index f74108dda6..ba1c303546 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -205,6 +205,9 @@ export interface AgentInstancesApi { ): Promise; get(id: string, options?: ReadOptions): Promise; + /** Restores a crashed runtime to its last retained snapshot. */ + revert(id: string): Promise; + create(input: { agent: ResourceRefInput; /** @@ -381,6 +384,7 @@ export function createApiClient(): KagentApiClient { invoke("agentInstances.rename", { id, name }), get: (id, options) => invoke("agentInstances.get", { id }, options), + revert: (id) => invoke("agentInstances.revert", { id }), create: (input) => invoke("agentInstances.create", input), remove: (id, options) => invoke("agentInstances.delete", { id, ...options }), fork: (id, name) => diff --git a/ui/src/api/domain/agentInstances.ts b/ui/src/api/domain/agentInstances.ts index 6c85dde927..50377a6213 100644 --- a/ui/src/api/domain/agentInstances.ts +++ b/ui/src/api/domain/agentInstances.ts @@ -59,6 +59,17 @@ export type AgentInstanceOperation = | "revert" | "unknown"; +export interface SessionMessageReference { + taskId: string; + messageId: string; +} + +export interface AgentInstanceRecovery { + /** Absent when the controller could not observe the runtime. */ + needed?: boolean; + failedMessages: SessionMessageReference[]; +} + /** * Why an instance failed, as the controller recorded it. * @@ -107,6 +118,7 @@ export interface AgentInstance { operation: AgentInstanceOperation; /** Set only when the controller recorded a failure. */ failure?: AgentInstanceFailure; + recovery?: AgentInstanceRecovery; /** RFC3339, or empty when the record carried no timestamp. */ createdAt: string; /** RFC3339, or empty when the record carried no timestamp. */ diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index 2ae8b2d8bc..4497d315a1 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -605,6 +605,15 @@ function toAgentInstance(instance: PbSession): AgentInstance { message: orUndefined(instance.failure.message), } : undefined, + recovery: instance.recovery + ? { + needed: instance.recovery.needed, + failedMessages: instance.recovery.failedMessages.map((message) => ({ + taskId: message.taskId, + messageId: message.messageId, + })), + } + : undefined, createdAt: isoFrom(instance.createdAt), updatedAt: isoFrom(instance.updatedAt), }; @@ -677,6 +686,7 @@ const agentInstances: Pick< | "agentInstances.rename" | "agentInstances.fork" | "agentInstances.delete" + | "agentInstances.revert" > = { "agentInstances.list": async (input, options) => { const name = "SessionService/ListSessions"; @@ -952,6 +962,16 @@ const agentInstances: Pick< return toAgentInstance(required(response.session, name, "agent instance")); }, + "agentInstances.revert": async (input, options) => { + const name = "SessionService/RevertSession"; + const response = await rpc(name, options.signal, () => + serviceClient(SessionService).revertSession( + { sessionId: input.id }, + call("agentInstances.revert", options), + ), + ); + return toAgentInstance(required(response.session, name, "agent instance")); + }, }; // endregion diff --git a/ui/src/api/operations.test.ts b/ui/src/api/operations.test.ts index b862f8b9c7..5f9c866222 100644 --- a/ui/src/api/operations.test.ts +++ b/ui/src/api/operations.test.ts @@ -994,6 +994,40 @@ describe("agent instances", () => { expect(isNotFound(missing)).toBe(true); }); + it("reverts through the existing Session RPC and retains exact failed message references", async () => { + let received: string | undefined; + serve(({ service }) => { + service(SessionService, { + revertSession: (request) => { + received = request.sessionId; + return { session: instanceMessage({ + state: RuntimeState.SUSPENDED, + recovery: { needed: false, failedMessages: [ + { taskId: "completed-task", messageId: "discarded-message" }, + ] }, + }) }; + }, + }); + }); + const reverted = await apiClient.agentInstances.revert(INSTANCE_ID); + expect(received).toBe(INSTANCE_ID); + expect(reverted.state).toBe("suspended"); + expect(reverted.recovery).toEqual({ needed: false, failedMessages: [ + { taskId: "completed-task", messageId: "discarded-message" }, + ] }); + }); + + it("keeps an unknown recovery observation distinct from not needing recovery", async () => { + serve(({ service }) => { + service(SessionService, { + getSession: () => ({ session: instanceMessage({ recovery: {} }) }), + }); + }); + expect((await apiClient.agentInstances.get(INSTANCE_ID)).recovery).toEqual({ + needed: undefined, failedMessages: [], + }); + }); + }); describe("session shares", () => { diff --git a/ui/src/api/operations.ts b/ui/src/api/operations.ts index 77c8238674..2401cf116d 100644 --- a/ui/src/api/operations.ts +++ b/ui/src/api/operations.ts @@ -290,6 +290,7 @@ export interface OperationMap { output: void; }; + "agentInstances.revert": { input: AgentInstanceRef; output: AgentInstance }; "harnesses.list": { input: { namespace?: string }; output: Harness[] }; /** diff --git a/ui/src/mocks/mockBackend.test.ts b/ui/src/mocks/mockBackend.test.ts index 2d0cefebcf..1dbe102d8e 100644 --- a/ui/src/mocks/mockBackend.test.ts +++ b/ui/src/mocks/mockBackend.test.ts @@ -108,6 +108,7 @@ const INPUTS = { id: "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44", }, + "agentInstances.revert": { id: "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44" }, "agentInstances.shares.list": { id: "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44", @@ -268,8 +269,10 @@ describe("the fixture backend", () => { operationIds.map(async (id) => { try { await run(id); + if (id === "agentInstances.revert") return `${id}: a runtime that is not crashed accepted recovery`; return null; } catch (error) { + if (id === "agentInstances.revert" && error instanceof ApiError && error.code === "FailedPrecondition") return null; return `${id}: ${(error as Error).message}`; } }), diff --git a/ui/src/mocks/transport.ts b/ui/src/mocks/transport.ts index a944067b96..4ffc7686e0 100644 --- a/ui/src/mocks/transport.ts +++ b/ui/src/mocks/transport.ts @@ -657,6 +657,7 @@ function agentInstanceMessage( // turns it back into "not reported". preparedRevision: row.preparedRevision ?? "", a2aAuthority: row.a2aAuthority ?? "", + recovery: row.recovery, state: PB_STATE_BY_NAME[row.state], operation: PB_OPERATION_BY_NAME[row.operation], failure: row.failure @@ -838,6 +839,12 @@ on(SessionService.method.getSession, (input, call) => ({ ), })); +// Fixtures have no Substrate runtime whose crashed state can authorize a revert. +on(SessionService.method.revertSession, (input, call) => { + instanceFor(requireInstanceId(input.sessionId), call); + throw new ConnectError("The fixture runtime is not crashed", Code.FailedPrecondition); +}); + on(SessionService.method.createSession, (input, call) => { const namespace = requireNamespace(input.agent?.namespace ?? ""); if (!input.agent?.name.trim()) throw new ConnectError("an Agent is required", Code.InvalidArgument); From 5274c57d350646089b4abbf707f295b5c16359f0 Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Wed, 4 Nov 2026 15:17:36 -0800 Subject: [PATCH 04/12] feat(ui): offer manual recovery for crashed conversations 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 Co-Authored-By: Claude Opus 5.5 --- ui/src/api/hooks/useLiveTranscript.ts | 14 ++ ui/src/components/chat/ChatMessageItem.tsx | 25 +++ .../chat/ChatTranscript.recovery.test.tsx | 98 +++++++++++ ui/src/components/chat/ChatTranscript.tsx | 28 +++- .../chat/CheckpointDivider.test.tsx | 34 ++++ ui/src/components/chat/CheckpointDivider.tsx | 18 +- .../chat/SessionRecoveryAlert.test.tsx | 56 +++++++ .../components/chat/SessionRecoveryAlert.tsx | 52 ++++++ ui/src/components/chat/discardedTurns.ts | 44 +++++ ui/src/pages/AgentChatPage.recovery.test.tsx | 156 ++++++++++++++++++ ui/src/pages/AgentChatPage.tsx | 61 ++++++- ui/src/pages/SharedAgentPage.tsx | 9 +- 12 files changed, 575 insertions(+), 20 deletions(-) create mode 100644 ui/src/components/chat/ChatTranscript.recovery.test.tsx create mode 100644 ui/src/components/chat/CheckpointDivider.test.tsx create mode 100644 ui/src/components/chat/SessionRecoveryAlert.test.tsx create mode 100644 ui/src/components/chat/SessionRecoveryAlert.tsx create mode 100644 ui/src/components/chat/discardedTurns.ts create mode 100644 ui/src/pages/AgentChatPage.recovery.test.tsx diff --git a/ui/src/api/hooks/useLiveTranscript.ts b/ui/src/api/hooks/useLiveTranscript.ts index 1c04604848..a290e6a18d 100644 --- a/ui/src/api/hooks/useLiveTranscript.ts +++ b/ui/src/api/hooks/useLiveTranscript.ts @@ -48,3 +48,17 @@ export function useLiveTranscript( }; }, [enabled, isBusy]); } + +/** + * Re-reads a conversation's transcript and its Session record together. + * + * Settled rather than awaited as one: a failed Session read must not drop the + * transcript refresh, and the reverse. Each keeps its previous reading on failure + * and the next tick tries again, so neither rejection escapes as unhandled. + */ +export async function refreshConversation( + transcript: () => Promise, + session: () => Promise, +): Promise { + await Promise.allSettled([transcript(), session()]); +} diff --git a/ui/src/components/chat/ChatMessageItem.tsx b/ui/src/components/chat/ChatMessageItem.tsx index 8e9a0f28df..7a57e0cecb 100644 --- a/ui/src/components/chat/ChatMessageItem.tsx +++ b/ui/src/components/chat/ChatMessageItem.tsx @@ -12,6 +12,7 @@ import { ToolApprovalRecord } from "./ToolApprovalRecord"; import { AskUserRecord } from "./AskUserRecord"; import { AttachmentChip } from "./AttachmentChip"; import { isAwaitingContent, messageText } from "./messageText"; +import type { DiscardedMark } from "./discardedTurns"; const { Text } = Typography; @@ -29,6 +30,7 @@ export function ChatMessageItem({ message, sessionId, isCheckpointed = false, + discarded, onManagePush, }: { message: ChatMessage; @@ -36,6 +38,8 @@ export function ChatMessageItem({ sessionId?: string; /** Whether this message is above the nearest saved boundary, for the browser suite. */ isCheckpointed?: boolean; + /** Whether a runtime recovery discarded this message, and how; see `discardedMessages`. */ + discarded?: DiscardedMark; /** Opens the callback settings for this message's task. */ onManagePush?: () => void; }) { @@ -54,6 +58,7 @@ export function ChatMessageItem({ data-message-id={message.id} data-role={message.role} data-checkpointed={isCheckpointed || undefined} + data-discarded={discarded} css={{ display: "grid", gap: theme.space(2), @@ -72,6 +77,13 @@ export function ChatMessageItem({ {isUser ? "You" : "Agent"} + {/* Labelled in the header rather than only dimmed: the reader message above it + carries the explanation, and a dimmed reply on its own reads as styling. */} + {discarded === "reply" ? ( + + · Not remembered after recovery + + ) : null} {onManagePush ? ( + + )} + /> + ); +} diff --git a/ui/src/components/chat/discardedTurns.ts b/ui/src/components/chat/discardedTurns.ts new file mode 100644 index 0000000000..0d85369f24 --- /dev/null +++ b/ui/src/components/chat/discardedTurns.ts @@ -0,0 +1,44 @@ +import type { AgentInstanceRecovery, ChatMessage } from "@/api"; + +/** + * How a message relates to work a runtime recovery discarded. + * + * - `message`: a reader message the controller listed as discarded. It did reach the + * agent, which may have started answering, so it is not a message that failed to send. + * - `reply`: anything after that message in the same task. The controller lists reader + * messages only, but the agent output that followed one was produced by the runtime + * state the recovery threw away, so it is unremembered too. + */ +export type DiscardedMark = "message" | "reply"; + +/** + * Marks each message the recovered runtime does not remember, by message id. + * + * A message is matched on both its task and message id, because ids are only unique + * within a task. Marking starts at the first listed message of a task, not at the task: + * a task can hold answers captured in the snapshot before the crash, and those stay + * unmarked. This reads transcript order, so it marks a reply correctly only when the + * transcript places the reply after the message it answers. + */ +export function discardedMessages( + messages: readonly ChatMessage[], + recovery: AgentInstanceRecovery | undefined, +): ReadonlyMap { + const marks = new Map(); + const failed = recovery?.failedMessages ?? []; + if (failed.length === 0) return marks; + + const listed = new Set(failed.map((ref) => `${ref.taskId}\u0000${ref.messageId}`)); + const discardedTasks = new Set(); + for (const message of messages) { + const taskId = message.taskId; + if (!taskId) continue; + if (listed.has(`${taskId}\u0000${message.id}`)) { + marks.set(message.id, "message"); + discardedTasks.add(taskId); + } else if (discardedTasks.has(taskId)) { + marks.set(message.id, "reply"); + } + } + return marks; +} diff --git a/ui/src/pages/AgentChatPage.recovery.test.tsx b/ui/src/pages/AgentChatPage.recovery.test.tsx new file mode 100644 index 0000000000..4639c5c646 --- /dev/null +++ b/ui/src/pages/AgentChatPage.recovery.test.tsx @@ -0,0 +1,156 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThemeProvider } from "@emotion/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { SWRConfig } from "swr"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRouterTransport } from "@connectrpc/connect"; +import { SessionService } from "@/generated/kagent/api/v1/sessions_pb"; +import { CheckpointService } from "@/generated/kagent/api/v1/checkpoints_pb"; +import { RuntimeOperation, RuntimeState } from "@/generated/kagent/api/v1/runtime_pb"; +import { setApiTransport } from "@/api/transport"; +import type { ChatController } from "@/api"; +import { themeFor } from "@/theme/theme"; +import { AgentChatPage } from "./AgentChatPage"; + +/* + * The page, against a fake controller whose Session can change under it. + * + * Only the chat controller is replaced: it speaks A2A, which is not what these tests + * are about. Every Session read goes through the real hooks and the real client, so a + * test that passes here means the page itself discovers the crash and reacts to it. + */ +const chat = { + messages: [], + isLoadingHistory: false, + turnState: "idle", + turnPhase: "idle", + phase: "idle", + refreshTranscript: vi.fn(() => Promise.resolve()), + send: vi.fn(), + cancel: vi.fn(), + dismissQuestion: vi.fn(), + answerQuestion: vi.fn(), + answerToolApproval: vi.fn(), + retry: vi.fn(), +} as unknown as ChatController; + +vi.mock("@/api", async (importOriginal) => ({ + ...(await importOriginal()), + useChat: () => chat, +})); + +const ID = "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44"; +const LIVE_REFRESH_MS = 4_000; + +type Recovery = { needed?: boolean; failedMessages: { taskId: string; messageId: string }[] }; + +function session(recovery?: Recovery) { + return { + id: ID, + contextId: ID, + creator: "alice@example.com", + agent: { ref: { namespace: "kagent", name: "assistant" }, uid: "agent-uid" }, + state: RuntimeState.READY, + operation: RuntimeOperation.NONE, + recovery, + }; +} + +const HEALTHY: Recovery = { needed: false, failedMessages: [] }; +const CRASHED: Recovery = { needed: true, failedMessages: [] }; + +let backend: Recovery; +let getSession: ReturnType; +let revertSession: ReturnType; + +function renderPage() { + setApiTransport(createRouterTransport(({ service }) => { + service(SessionService, { + getSession: (getSession = vi.fn(() => ({ session: session(backend) }))), + listSessions: () => ({ sessions: [session(backend)] }), + revertSession: (revertSession = vi.fn(() => { + backend = HEALTHY; + return { session: session(backend) }; + })), + }); + service(CheckpointService, { listCheckpoints: () => ({ checkpoints: [] }) }); + })); + render( + + new Map(), shouldRetryOnError: false, revalidateOnFocus: false }}> + + + } /> + + + + , + ); +} + +const composer = () => screen.getByTestId("chat-input"); + +/** Fires interval ticks, then lets the reads they started settle. */ +async function tick(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + }); +} + +beforeEach(() => { + // Only intervals are faked: the page's polls are intervals, and SWR and the + // transport keep their real timeouts and promises. + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + vi.mocked(chat.refreshTranscript).mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); + setApiTransport(undefined); +}); + +describe("a crash found on an open conversation", () => { + it("finds a crash that happens after the page went idle", async () => { + backend = HEALTHY; + renderPage(); + await waitFor(() => expect(composer()).toBeEnabled()); + expect(screen.queryByText("The agent runtime crashed")).not.toBeInTheDocument(); + + // The Actor crashes with no turn running, so nothing on the page is polling closely. + backend = CRASHED; + await tick(LIVE_REFRESH_MS); + + expect(await screen.findByText("The agent runtime crashed")).toBeInTheDocument(); + expect(composer()).toBeDisabled(); + }); + + it("does not re-read a crashed Session every second while it waits on the reader", async () => { + backend = CRASHED; + renderPage(); + await screen.findByText("The agent runtime crashed"); + await waitFor(() => expect(composer()).toBeDisabled()); + + const before = getSession.mock.calls.length; + await tick(LIVE_REFRESH_MS - 1); + expect(getSession.mock.calls.length).toBe(before); + await tick(1); + await waitFor(() => expect(getSession.mock.calls.length).toBe(before + 1)); + }); + + it("reverts, then re-reads the Session and the transcript and enables the composer", async () => { + backend = CRASHED; + renderPage(); + await screen.findByText("The agent runtime crashed"); + expect(composer()).toBeDisabled(); + const reads = getSession.mock.calls.length; + + await userEvent.click(screen.getByRole("button", { name: "Revert to last snapshot" })); + + await waitFor(() => expect(composer()).toBeEnabled()); + expect(revertSession).toHaveBeenCalledOnce(); + expect(getSession.mock.calls.length).toBeGreaterThan(reads); + expect(chat.refreshTranscript).toHaveBeenCalled(); + expect(screen.queryByText("The agent runtime crashed")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/pages/AgentChatPage.tsx b/ui/src/pages/AgentChatPage.tsx index e2241495de..f8f13f69d0 100644 --- a/ui/src/pages/AgentChatPage.tsx +++ b/ui/src/pages/AgentChatPage.tsx @@ -13,6 +13,7 @@ import { ConversationDetailsModal } from "@/components/chat/ConversationDetailsM import { SnapshotDetailsModal } from "@/components/chat/SnapshotDetailsModal"; import { SnapshotRenameDialog } from "@/components/chat/SnapshotRenameDialog"; import { ChatTranscript } from "@/components/chat/ChatTranscript"; +import { SessionRecoveryAlert } from "@/components/chat/SessionRecoveryAlert"; import { TaskPushNotificationsModal } from "@/components/chat/TaskPushNotificationsModal"; import { isLifecycleBusy } from "@/components/chat/lifecycleReading"; import { paths } from "@/router/routes"; @@ -24,7 +25,7 @@ import { useChat, } from "@/api"; import { autoTitleFrom } from "@/components/agent-instances/instanceLabels"; -import { useLiveTranscript } from "@/api/hooks/useLiveTranscript"; +import { refreshConversation, useLiveTranscript } from "@/api/hooks/useLiveTranscript"; import { useInvalidateConversations } from "@/api/hooks/useInvalidateConversations"; import { useCheckpoints } from "@/api/hooks/useCheckpoints"; import type { Checkpoint } from "@/api"; @@ -114,8 +115,17 @@ export function AgentChatPage() { const chat = useChat(conversation); const [pushTask, setPushTask] = useState<{ conversationId: string; taskId: string }>(); - // Keep up with messages sent from other tabs or clients. - useLiveTranscript(chat.refreshTranscript, { + /* + * Keep up with messages sent from other tabs or clients, and with the Session itself. + * + * The Session is re-read on the same tick because a runtime can crash while nothing + * here is watching: a turn settles, the lifecycle poll below stops, and the Actor + * crashes during automatic suspension. A transcript-only refresh would never learn + * that, so the composer would stay enabled and the recovery choices hidden until the + * reader reloaded. Riding this refresh costs one read per tick while the tab is + * visible, rather than widening the one-second lifecycle poll to every idle page. + */ + useLiveTranscript(() => refreshConversation(chat.refreshTranscript, instance.refresh), { enabled: Boolean(conversation), isBusy: chat.phase === "streaming", }); @@ -305,7 +315,8 @@ export function AgentChatPage() { // Automatic runtime quiescence leaves the conversation ready for its next turn. const state = instance.data?.state; - const canSend = state === "ready"; + const recovery = instance.data?.recovery; + const canSend = state === "ready" && recovery?.needed !== true; /* * Watching the instance while it is doing something. @@ -427,7 +438,36 @@ export function AgentChatPage() { // and depending on it would re-run this on each one. // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, chat.isLoadingHistory]); - const isBusy = isLifecycleBusy(instance.data, chat.turnPhase, isAwaitingReply); + const [isReverting, setReverting] = useState(false); + const revertRuntime = useCallback(async () => { + if (!id) return; + setReverting(true); + let reverted = false; + try { + await apiClient.agentInstances.revert(id); + reverted = true; + await instance.refresh(); + await chat.refreshTranscript(); + } catch (cause: unknown) { + const reason = cause instanceof Error ? cause.message : String(cause); + toast.error(reverted + ? `Recovery completed. Could not refresh the conversation: ${reason}` + : `Could not recover the conversation: ${reason}`); + } finally { + setReverting(false); + } + }, [id, instance, chat]); + + /* + * A crashed conversation waiting on the reader is idle, so `recovery.needed` alone does + * not keep the one-second poll running: every read of a crashed Session also asks + * Substrate for its Actor, and a page left open would do that until someone acted. + * The poll runs while a revert is in flight or the controller has claimed a resume, + * which `isLifecycleBusy` already reads. The live refresh above still notices a + * recovery made from another tab or by the controller. + */ + const isBusy = isLifecycleBusy(instance.data, chat.turnPhase, isAwaitingReply) + || isReverting; /* * The conversation list is deliberately not re-read when this one's state changes. @@ -620,7 +660,14 @@ export function AgentChatPage() { /> ) : null} - {instance.data && !canSend ? ( + + + {instance.data && !canSend && recovery?.needed !== true ? ( refreshConversation(chat.refreshTranscript, instance.refresh), { enabled: Boolean(conversation), isBusy: chat.phase === "streaming", }); @@ -92,7 +93,7 @@ export function SharedAgentPage() { height: `calc(100vh - ${theme.layout.headerHeight}px - ${theme.space(48)})`, }} > - + )} From 0af7fdcf32f7e000ec176d7ef7506f7794de7c0d Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Tue, 27 Oct 2026 15:29:10 -0700 Subject: [PATCH 05/12] test(e2e): retry resumes that wake a suspended Actor 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 Co-Authored-By: Claude Opus 5.5 --- go/core/test/e2e/interaction_test.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 8a77a0d16c..50909486ee 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -22,7 +22,6 @@ import ( "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" - a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" "github.com/google/uuid" @@ -196,7 +195,7 @@ func TestSessionAskUserSurvivesSuspension(t *testing.T) { Answers: []kagenta2a.AskUserAnswer{{Answer: []string{"PostgreSQL"}}}, }) reply.TaskID, reply.ContextID = waiting.ID, waiting.ContextID - response, err := a2agrpc.NewGRPCTransportFromClient(fixture.client).SendMessage(fixture.ctx, nil, &a2atype.SendMessageRequest{Tenant: fixture.tenant, Message: reply}) + response, err := sendResume(t, fixture, reply) if err != nil { t.Fatalf("resume A2A task: %v", err) } @@ -207,6 +206,23 @@ func TestSessionAskUserSurvivesSuspension(t *testing.T) { }) } +// sendResume answers a waiting task after its Actor was suspended. Waking the +// Actor can fail before the runtime accepts the reply; the server then answers +// KAGENT_SEND_NOT_ACCEPTED, and a client retries that send as it would any other. +func sendResume(t *testing.T, fixture *interactionFixture, reply *a2atype.Message) (a2atype.SendMessageResult, error) { + t.Helper() + request, err := pbconv.ToProtoSendMessageRequest(&a2atype.SendMessageRequest{Message: reply}) + if err != nil { + t.Fatalf("build resume request: %v", err) + } + request.Tenant = fixture.tenant + response, err := sendMessageWithRetry(fixture.ctx, fixture.client, request) + if err != nil { + return nil, err + } + return pbconv.FromProtoSendMessageResponse(response) +} + func sendToolDecision(t *testing.T, fixture *interactionFixture, prompt, wantTool string, approved bool) *a2atype.Task { t.Helper() fixture.ctx = metadata.AppendToOutgoingContext(fixture.ctx, strings.ToLower(a2atype.SvcParamExtensions), kagenta2a.HITLExtensionURI) @@ -242,7 +258,7 @@ func sendToolDecision(t *testing.T, fixture *interactionFixture, prompt, wantToo }); err != nil { t.Fatalf("attach tool approval response: %v", err) } - response, err := a2agrpc.NewGRPCTransportFromClient(fixture.client).SendMessage(fixture.ctx, nil, &a2atype.SendMessageRequest{Tenant: fixture.tenant, Message: reply}) + response, err := sendResume(t, fixture, reply) if err != nil { t.Fatalf("resume A2A task after tool approval: %v", err) } From 7c5901f91a34c91b1a0f8a915469dc19e057ff82 Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Thu, 5 Nov 2026 15:57:48 -0800 Subject: [PATCH 06/12] test(session): expect a healthy suspended runtime to return from Revert 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 Signed-off-by: Ada Lundhe --- .../session/recovery_observation_test.go | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/go/core/internal/service/session/recovery_observation_test.go b/go/core/internal/service/session/recovery_observation_test.go index 24df9753fc..b7a3f8ae84 100644 --- a/go/core/internal/service/session/recovery_observation_test.go +++ b/go/core/internal/service/session/recovery_observation_test.go @@ -19,12 +19,13 @@ import ( func TestSessionRecoveryObservation(t *testing.T) { for _, test := range []struct { - name string - crashed, replaced, missing bool - readErr error - want *bool + name string + running, crashed, replaced, missing bool + readErr error + want *bool }{ {name: "healthy", want: new(false)}, + {name: "running", running: true, want: new(false)}, {name: "crashed", crashed: true, want: new(true)}, {name: "missing", missing: true}, {name: "replaced", crashed: true, replaced: true}, @@ -33,11 +34,19 @@ func TestSessionRecoveryObservation(t *testing.T) { t.Run(test.name, func(t *testing.T) { store, session := lifecycleFixture(t) base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} - actors := &recoveryTestActors{lifecycleTestActors: base, revert: base.RevertActor} + reverts := 0 + actors := &recoveryTestActors{lifecycleTestActors: base} + actors.revert = func(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { + reverts++ + return base.RevertActor(ctx, atespace, name) + } workflow := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute) session, err := workflow.Create(t.Context(), session) require.NoError(t, err) key := actorKey("team-a", substrate.ActorName(session.Id)) + if test.running { + base.actors[key].Status.State = ateapipb.ActorState_ACTOR_STATE_RUNNING + } if test.crashed { base.actors[key].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED } @@ -64,11 +73,13 @@ func TestSessionRecoveryObservation(t *testing.T) { require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, stored.State) require.Nil(t, stored.Recovery, "reads do not admit recovery or persist observations") - // What chat offers is what Revert does: needed admits a revert, - // healthy refuses one, and an unknown observation admits nothing. - // A missing or replaced Actor is refused for good, so a client stops - // retrying; only a failed read stays retryable. - _, err = workflow.Revert(t.Context(), session) + // What chat offers is what Revert does: needed admits a revert, a + // healthy runtime is never reverted, and an unknown observation admits + // nothing. A suspended healthy runtime returns the Session unchanged, + // as a retry after a lost revert response does; a running one is + // refused. A missing or replaced Actor is refused for good, so a client + // stops retrying; only a failed read stays retryable. + reverted, err := workflow.Revert(t.Context(), session) switch { case test.missing || test.replaced: require.ErrorIs(t, err, errRuntimeUnrecoverable) @@ -78,8 +89,14 @@ func TestSessionRecoveryObservation(t *testing.T) { case *test.want: require.NoError(t, err) default: - require.NoError(t, err, "a healthy runtime has nothing to recover") + require.NoError(t, err) + require.True(t, proto.Equal(session, reverted), "a healthy Session is returned unchanged") + } + wantReverts := 0 + if test.want != nil && *test.want { + wantReverts = 1 } + require.Equal(t, wantReverts, reverts, "only a confirmed crash is reverted") }) } } From 8ea52920fa396fabcd0debaeb9cdeaafd90a1628 Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Thu, 5 Nov 2026 18:07:08 -0800 Subject: [PATCH 07/12] refactor(session): report recovery as one GetSession field 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 Signed-off-by: Ada Lundhe --- docs/architecture/runtime-and-lifecycle.md | 21 +- docs/lifecycle-retries.md | 12 +- go/api/gen/kagent/api/v1/sessions.pb.go | 418 +++++++----------- go/core/internal/database/session_recovery.go | 76 ---- .../session_recovery_messages_test.go | 138 ------ .../grpcserver/session_recovery_test.go | 18 +- go/core/internal/grpcserver/session_test.go | 13 +- go/core/internal/mcp/tasks_test.go | 2 +- go/core/internal/service/session/recovery.go | 34 +- .../service/session/recovery_messages_test.go | 147 ------ .../session/recovery_observation_test.go | 70 +-- go/core/internal/service/session/service.go | 29 +- .../internal/service/session/service_test.go | 10 +- go/core/test/e2e/session_revert_test.go | 3 + proto/kagent/api/v1/sessions.proto | 23 +- ui/src/api/domain/agentInstances.ts | 14 +- ui/src/api/grpc/operations.ts | 10 +- ui/src/api/operations.test.ts | 23 +- ui/src/components/chat/ChatMessageItem.tsx | 25 -- .../chat/ChatTranscript.recovery.test.tsx | 98 ---- ui/src/components/chat/ChatTranscript.tsx | 24 +- .../chat/SessionRecoveryAlert.test.tsx | 58 ++- .../components/chat/SessionRecoveryAlert.tsx | 29 +- ui/src/components/chat/discardedTurns.ts | 44 -- ui/src/generated/kagent/api/v1/sessions_pb.ts | 104 ++--- ui/src/mocks/mockBackend.test.ts | 2 - ui/src/mocks/transport.ts | 11 +- ui/src/pages/AgentChatPage.recovery.test.tsx | 90 +++- ui/src/pages/AgentChatPage.tsx | 30 +- ui/src/pages/SharedAgentPage.tsx | 9 +- 30 files changed, 444 insertions(+), 1141 deletions(-) delete mode 100644 go/core/internal/database/session_recovery_messages_test.go delete mode 100644 go/core/internal/service/session/recovery_messages_test.go delete mode 100644 ui/src/components/chat/ChatTranscript.recovery.test.tsx delete mode 100644 ui/src/components/chat/discardedTurns.ts diff --git a/docs/architecture/runtime-and-lifecycle.md b/docs/architecture/runtime-and-lifecycle.md index 60e6f7bb19..e48b892fe3 100644 --- a/docs/architecture/runtime-and-lifecycle.md +++ b/docs/architecture/runtime-and-lifecycle.md @@ -265,9 +265,9 @@ retires the claim in its transaction, so a late finish records nothing. Execution reverts the same Actor to its last durable snapshot without replaying input. A runtime without a durable snapshot returns to its initial state. An interrupted revert continues from REVERTING; a lost response can be reconciled -from SUSPENDED. Nothing retries a recovery in the background. An admitted -recovery survives API restarts as a durable REVERT operation, and the client -continues it by retrying RevertSession, as with Create and Delete. While it is +from SUSPENDED. An admitted recovery survives API restarts as a durable REVERT +operation, and the client continues it by retrying RevertSession, as with Create +and Delete. Nothing retries it in the background. While it is pending, `GetSession` reports FAILED/REVERT with failure reason `RuntimeCrashed`, and interactions are refused because a lifecycle operation is in progress. @@ -284,23 +284,14 @@ The next authorized interaction resumes it through Substrate ingress. Runtime changes after the retained snapshot are lost; the discarded turn receives no checkpoint at that older snapshot. Another revert returns the Session. -`GetSession` reports a read-only recovery observation for the recorded Actor. -It is the only Session read that calls Substrate; MCP and the other Session -responses read the stored record and omit `recovery`. An unavailable or +`GetSession` reports `recovery_needed`, a read-only observation of the recorded +Actor. It is the only Session read that calls Substrate; MCP and the other +Session responses read the stored record and leave it unset. An unavailable or replaced Actor leaves the observation unknown and does not prevent history reads. The observation and `RevertSession` share one crash check, so chat offers a revert exactly when the revert would be admitted. Chat offers that revert and the existing fork action for a saved READY checkpoint. -Recovery records exact task and message identities for user inputs after the -retained snapshot boundary, including accepted inputs not yet published. Chat -marks those inputs "Not remembered after recovery" and retains the public -transcript; completed A2A tasks and their history are not rewound, so a reply -to a marked input can still appear. The annotations remain on the recovered -Session. A revert to a snapshot that no history event records leaves the -boundary unknown and adds no annotations. Forks copy checkpoint-bounded history -into a fresh Session and do not inherit its delivery annotations. - ```mermaid sequenceDiagram participant Client diff --git a/docs/lifecycle-retries.md b/docs/lifecycle-retries.md index 9930daa855..29a6654426 100644 --- a/docs/lifecycle-retries.md +++ b/docs/lifecycle-retries.md @@ -41,18 +41,16 @@ should likewise retain the `request_id` passed to `create_sandbox`. ## Recovering a crashed Session -Chat offers a revert or a fork from an existing READY checkpoint after a confirmed -runtime crash. Call `SessionService.RevertSession` with the Session ID to restore -its recorded runtime to the last durable snapshot. +Recovery runs only when a client asks for it. Chat offers a revert or a fork from +an existing READY checkpoint after a confirmed runtime crash. Call +`SessionService.RevertSession` with the Session ID to restore its recorded runtime +to the last durable snapshot. The caller needs Session update access. The operation verifies Substrate's CRASHED state and the recorded Actor identity. Recovery fails interrupted turns and waiting tasks absent from the retained snapshot. It preserves durable terminal results and returns the Session to READY with its Actor suspended. It discards runtime changes since that snapshot and does not replay input. A runtime without a snapshot restarts from its initial state. -`GetSession.recovery.failed_messages` identifies user inputs absent from the -restored runtime by both task and message ID. Their public history remains -available, and chat marks them "Not remembered after recovery". Only -`GetSession` returns `recovery`. +`GetSession` reports `recovery_needed`; no other Session read sets it. Retry an interrupted revert against the same Session ID; nothing retries it in the background. Pending recovery retains its operation across API restarts, and a retry joins it. diff --git a/go/api/gen/kagent/api/v1/sessions.pb.go b/go/api/gen/kagent/api/v1/sessions.pb.go index ade832c216..0b6211b0ba 100644 --- a/go/api/gen/kagent/api/v1/sessions.pb.go +++ b/go/api/gen/kagent/api/v1/sessions.pb.go @@ -94,10 +94,12 @@ type Session struct { // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` - // Output only. Set only by GetSession; every other response leaves it unset. - Recovery *SessionRecovery `protobuf:"bytes,16,opt,name=recovery,proto3" json:"recovery,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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. + RecoveryNeeded *bool `protobuf:"varint,16,opt,name=recovery_needed,json=recoveryNeeded,proto3,oneof" json:"recovery_needed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Session) Reset() { @@ -214,124 +216,13 @@ func (x *Session) GetContextId() string { return "" } -func (x *Session) GetRecovery() *SessionRecovery { - if x != nil { - return x.Recovery - } - return nil -} - -type SessionRecovery struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Whether the recorded runtime crashed and needs a revert. Unset when - // GetSession could not observe the runtime; clients must not offer a revert then. - Needed *bool `protobuf:"varint,1,opt,name=needed,proto3,oneof" json:"needed,omitempty"` - // User messages the restored runtime does not remember, from every revert of - // this Session. Each appears once. Their A2A history remains available, - // including completed task results, so the list lasts as long as the - // Session and grows only when a revert discards input. - FailedMessages []*SessionMessageReference `protobuf:"bytes,3,rep,name=failed_messages,json=failedMessages,proto3" json:"failed_messages,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SessionRecovery) Reset() { - *x = SessionRecovery{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SessionRecovery) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SessionRecovery) ProtoMessage() {} - -func (x *SessionRecovery) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SessionRecovery.ProtoReflect.Descriptor instead. -func (*SessionRecovery) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{2} -} - -func (x *SessionRecovery) GetNeeded() bool { - if x != nil && x.Needed != nil { - return *x.Needed +func (x *Session) GetRecoveryNeeded() bool { + if x != nil && x.RecoveryNeeded != nil { + return *x.RecoveryNeeded } return false } -func (x *SessionRecovery) GetFailedMessages() []*SessionMessageReference { - if x != nil { - return x.FailedMessages - } - return nil -} - -// Message IDs are unique within a task, not across a conversation. -type SessionMessageReference struct { - state protoimpl.MessageState `protogen:"open.v1"` - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - MessageId string `protobuf:"bytes,2,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SessionMessageReference) Reset() { - *x = SessionMessageReference{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SessionMessageReference) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SessionMessageReference) ProtoMessage() {} - -func (x *SessionMessageReference) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SessionMessageReference.ProtoReflect.Descriptor instead. -func (*SessionMessageReference) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{3} -} - -func (x *SessionMessageReference) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *SessionMessageReference) GetMessageId() string { - if x != nil { - return x.MessageId - } - return "" -} - type AgentProvenance struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -342,7 +233,7 @@ type AgentProvenance struct { func (x *AgentProvenance) Reset() { *x = AgentProvenance{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -354,7 +245,7 @@ func (x *AgentProvenance) String() string { func (*AgentProvenance) ProtoMessage() {} func (x *AgentProvenance) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -367,7 +258,7 @@ func (x *AgentProvenance) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentProvenance.ProtoReflect.Descriptor instead. func (*AgentProvenance) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{4} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{2} } func (x *AgentProvenance) GetRef() *ResourceReference { @@ -396,7 +287,7 @@ type CreateSessionRequest struct { func (x *CreateSessionRequest) Reset() { *x = CreateSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -408,7 +299,7 @@ func (x *CreateSessionRequest) String() string { func (*CreateSessionRequest) ProtoMessage() {} func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -421,7 +312,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{5} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{3} } func (x *CreateSessionRequest) GetAgent() *ResourceReference { @@ -454,7 +345,7 @@ type CreateSessionResponse struct { func (x *CreateSessionResponse) Reset() { *x = CreateSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -466,7 +357,7 @@ func (x *CreateSessionResponse) String() string { func (*CreateSessionResponse) ProtoMessage() {} func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -479,7 +370,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{6} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{4} } func (x *CreateSessionResponse) GetSession() *Session { @@ -498,7 +389,7 @@ type GetSessionRequest struct { func (x *GetSessionRequest) Reset() { *x = GetSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -510,7 +401,7 @@ func (x *GetSessionRequest) String() string { func (*GetSessionRequest) ProtoMessage() {} func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -523,7 +414,7 @@ func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. func (*GetSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{7} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{5} } func (x *GetSessionRequest) GetSessionId() string { @@ -542,7 +433,7 @@ type GetSessionResponse struct { func (x *GetSessionResponse) Reset() { *x = GetSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -554,7 +445,7 @@ func (x *GetSessionResponse) String() string { func (*GetSessionResponse) ProtoMessage() {} func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -567,7 +458,7 @@ func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. func (*GetSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{8} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{6} } func (x *GetSessionResponse) GetSession() *Session { @@ -590,7 +481,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -602,7 +493,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -615,7 +506,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{9} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{7} } func (x *ListSessionsRequest) GetAllCreators() bool { @@ -649,7 +540,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -661,7 +552,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -674,7 +565,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{10} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{8} } func (x *ListSessionsResponse) GetSessions() []*Session { @@ -703,7 +594,7 @@ type UpdateSessionNameRequest struct { func (x *UpdateSessionNameRequest) Reset() { *x = UpdateSessionNameRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -715,7 +606,7 @@ func (x *UpdateSessionNameRequest) String() string { func (*UpdateSessionNameRequest) ProtoMessage() {} func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -728,7 +619,7 @@ func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameRequest.ProtoReflect.Descriptor instead. func (*UpdateSessionNameRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{11} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{9} } func (x *UpdateSessionNameRequest) GetSessionId() string { @@ -754,7 +645,7 @@ type UpdateSessionNameResponse struct { func (x *UpdateSessionNameResponse) Reset() { *x = UpdateSessionNameResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -766,7 +657,7 @@ func (x *UpdateSessionNameResponse) String() string { func (*UpdateSessionNameResponse) ProtoMessage() {} func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -779,7 +670,7 @@ func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameResponse.ProtoReflect.Descriptor instead. func (*UpdateSessionNameResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{12} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{10} } func (x *UpdateSessionNameResponse) GetSession() *Session { @@ -798,7 +689,7 @@ type RevertSessionRequest struct { func (x *RevertSessionRequest) Reset() { *x = RevertSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -810,7 +701,7 @@ func (x *RevertSessionRequest) String() string { func (*RevertSessionRequest) ProtoMessage() {} func (x *RevertSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -823,7 +714,7 @@ func (x *RevertSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertSessionRequest.ProtoReflect.Descriptor instead. func (*RevertSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{13} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{11} } func (x *RevertSessionRequest) GetSessionId() string { @@ -842,7 +733,7 @@ type RevertSessionResponse struct { func (x *RevertSessionResponse) Reset() { *x = RevertSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -854,7 +745,7 @@ func (x *RevertSessionResponse) String() string { func (*RevertSessionResponse) ProtoMessage() {} func (x *RevertSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -867,7 +758,7 @@ func (x *RevertSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertSessionResponse.ProtoReflect.Descriptor instead. func (*RevertSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{14} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{12} } func (x *RevertSessionResponse) GetSession() *Session { @@ -889,7 +780,7 @@ type DeleteSessionRequest struct { func (x *DeleteSessionRequest) Reset() { *x = DeleteSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -901,7 +792,7 @@ func (x *DeleteSessionRequest) String() string { func (*DeleteSessionRequest) ProtoMessage() {} func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -914,7 +805,7 @@ func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{15} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{13} } func (x *DeleteSessionRequest) GetSessionId() string { @@ -940,7 +831,7 @@ type DeleteSessionResponse struct { func (x *DeleteSessionResponse) Reset() { *x = DeleteSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -952,7 +843,7 @@ func (x *DeleteSessionResponse) String() string { func (*DeleteSessionResponse) ProtoMessage() {} func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -965,7 +856,7 @@ func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{16} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{14} } func (x *DeleteSessionResponse) GetSession() *Session { @@ -989,7 +880,7 @@ type SessionShare struct { func (x *SessionShare) Reset() { *x = SessionShare{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1001,7 +892,7 @@ func (x *SessionShare) String() string { func (*SessionShare) ProtoMessage() {} func (x *SessionShare) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1014,7 +905,7 @@ func (x *SessionShare) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionShare.ProtoReflect.Descriptor instead. func (*SessionShare) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{17} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{15} } func (x *SessionShare) GetId() string { @@ -1058,7 +949,7 @@ type CreateSessionShareRequest struct { func (x *CreateSessionShareRequest) Reset() { *x = CreateSessionShareRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1070,7 +961,7 @@ func (x *CreateSessionShareRequest) String() string { func (*CreateSessionShareRequest) ProtoMessage() {} func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1083,7 +974,7 @@ func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareRequest.ProtoReflect.Descriptor instead. func (*CreateSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{18} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{16} } func (x *CreateSessionShareRequest) GetSessionId() string { @@ -1111,7 +1002,7 @@ type CreateSessionShareResponse struct { func (x *CreateSessionShareResponse) Reset() { *x = CreateSessionShareResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1123,7 +1014,7 @@ func (x *CreateSessionShareResponse) String() string { func (*CreateSessionShareResponse) ProtoMessage() {} func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1136,7 +1027,7 @@ func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareResponse.ProtoReflect.Descriptor instead. func (*CreateSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{19} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{17} } func (x *CreateSessionShareResponse) GetShare() *SessionShare { @@ -1163,7 +1054,7 @@ type ListSessionSharesRequest struct { func (x *ListSessionSharesRequest) Reset() { *x = ListSessionSharesRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1175,7 +1066,7 @@ func (x *ListSessionSharesRequest) String() string { func (*ListSessionSharesRequest) ProtoMessage() {} func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1188,7 +1079,7 @@ func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesRequest.ProtoReflect.Descriptor instead. func (*ListSessionSharesRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{20} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{18} } func (x *ListSessionSharesRequest) GetSessionId() string { @@ -1215,7 +1106,7 @@ type ListSessionSharesResponse struct { func (x *ListSessionSharesResponse) Reset() { *x = ListSessionSharesResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1227,7 +1118,7 @@ func (x *ListSessionSharesResponse) String() string { func (*ListSessionSharesResponse) ProtoMessage() {} func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1240,7 +1131,7 @@ func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesResponse.ProtoReflect.Descriptor instead. func (*ListSessionSharesResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{21} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{19} } func (x *ListSessionSharesResponse) GetShares() []*SessionShare { @@ -1266,7 +1157,7 @@ type RevokeSessionShareRequest struct { func (x *RevokeSessionShareRequest) Reset() { *x = RevokeSessionShareRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[22] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1278,7 +1169,7 @@ func (x *RevokeSessionShareRequest) String() string { func (*RevokeSessionShareRequest) ProtoMessage() {} func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[22] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1291,7 +1182,7 @@ func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareRequest.ProtoReflect.Descriptor instead. func (*RevokeSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{22} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{20} } func (x *RevokeSessionShareRequest) GetShareId() string { @@ -1309,7 +1200,7 @@ type RevokeSessionShareResponse struct { func (x *RevokeSessionShareResponse) Reset() { *x = RevokeSessionShareResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[23] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1321,7 +1212,7 @@ func (x *RevokeSessionShareResponse) String() string { func (*RevokeSessionShareResponse) ProtoMessage() {} func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[23] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1334,7 +1225,7 @@ func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareResponse.ProtoReflect.Descriptor instead. func (*RevokeSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{23} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{21} } var File_kagent_api_v1_sessions_proto protoreflect.FileDescriptor @@ -1344,7 +1235,7 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "\x1ckagent/api/v1/sessions.proto\x12\rkagent.api.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1akagent/api/v1/common.proto\x1a\x1bkagent/api/v1/runtime.proto\";\n" + "\aFailure\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\xc4\x04\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\xca\x04\n" + "\aSession\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\acreator\x18\x02 \x01(\tR\acreator\x124\n" + @@ -1361,16 +1252,9 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "updated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x12\n" + "\x04name\x18\r \x01(\tR\x04name\x12\x1d\n" + "\n" + - "context_id\x18\x0e \x01(\tR\tcontextId\x12:\n" + - "\brecovery\x18\x10 \x01(\v2\x1e.kagent.api.v1.SessionRecoveryR\brecovery\"\x8a\x01\n" + - "\x0fSessionRecovery\x12\x1b\n" + - "\x06needed\x18\x01 \x01(\bH\x00R\x06needed\x88\x01\x01\x12O\n" + - "\x0ffailed_messages\x18\x03 \x03(\v2&.kagent.api.v1.SessionMessageReferenceR\x0efailedMessagesB\t\n" + - "\a_needed\"Q\n" + - "\x17SessionMessageReference\x12\x17\n" + - "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n" + - "\n" + - "message_id\x18\x02 \x01(\tR\tmessageId\"W\n" + + "context_id\x18\x0e \x01(\tR\tcontextId\x12,\n" + + "\x0frecovery_needed\x18\x10 \x01(\bH\x00R\x0erecoveryNeeded\x88\x01\x01B\x12\n" + + "\x10_recovery_needed\"W\n" + "\x0fAgentProvenance\x122\n" + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceR\x03ref\x12\x10\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\"\xd4\x01\n" + @@ -1460,90 +1344,86 @@ func file_kagent_api_v1_sessions_proto_rawDescGZIP() []byte { return file_kagent_api_v1_sessions_proto_rawDescData } -var file_kagent_api_v1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_kagent_api_v1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 22) var file_kagent_api_v1_sessions_proto_goTypes = []any{ (*Failure)(nil), // 0: kagent.api.v1.Failure (*Session)(nil), // 1: kagent.api.v1.Session - (*SessionRecovery)(nil), // 2: kagent.api.v1.SessionRecovery - (*SessionMessageReference)(nil), // 3: kagent.api.v1.SessionMessageReference - (*AgentProvenance)(nil), // 4: kagent.api.v1.AgentProvenance - (*CreateSessionRequest)(nil), // 5: kagent.api.v1.CreateSessionRequest - (*CreateSessionResponse)(nil), // 6: kagent.api.v1.CreateSessionResponse - (*GetSessionRequest)(nil), // 7: kagent.api.v1.GetSessionRequest - (*GetSessionResponse)(nil), // 8: kagent.api.v1.GetSessionResponse - (*ListSessionsRequest)(nil), // 9: kagent.api.v1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 10: kagent.api.v1.ListSessionsResponse - (*UpdateSessionNameRequest)(nil), // 11: kagent.api.v1.UpdateSessionNameRequest - (*UpdateSessionNameResponse)(nil), // 12: kagent.api.v1.UpdateSessionNameResponse - (*RevertSessionRequest)(nil), // 13: kagent.api.v1.RevertSessionRequest - (*RevertSessionResponse)(nil), // 14: kagent.api.v1.RevertSessionResponse - (*DeleteSessionRequest)(nil), // 15: kagent.api.v1.DeleteSessionRequest - (*DeleteSessionResponse)(nil), // 16: kagent.api.v1.DeleteSessionResponse - (*SessionShare)(nil), // 17: kagent.api.v1.SessionShare - (*CreateSessionShareRequest)(nil), // 18: kagent.api.v1.CreateSessionShareRequest - (*CreateSessionShareResponse)(nil), // 19: kagent.api.v1.CreateSessionShareResponse - (*ListSessionSharesRequest)(nil), // 20: kagent.api.v1.ListSessionSharesRequest - (*ListSessionSharesResponse)(nil), // 21: kagent.api.v1.ListSessionSharesResponse - (*RevokeSessionShareRequest)(nil), // 22: kagent.api.v1.RevokeSessionShareRequest - (*RevokeSessionShareResponse)(nil), // 23: kagent.api.v1.RevokeSessionShareResponse - (RuntimeState)(0), // 24: kagent.api.v1.RuntimeState - (RuntimeOperation)(0), // 25: kagent.api.v1.RuntimeOperation - (*timestamppb.Timestamp)(nil), // 26: google.protobuf.Timestamp - (*ResourceReference)(nil), // 27: kagent.api.v1.ResourceReference - (*PageRequest)(nil), // 28: kagent.api.v1.PageRequest - (*PageResponse)(nil), // 29: kagent.api.v1.PageResponse - (*durationpb.Duration)(nil), // 30: google.protobuf.Duration + (*AgentProvenance)(nil), // 2: kagent.api.v1.AgentProvenance + (*CreateSessionRequest)(nil), // 3: kagent.api.v1.CreateSessionRequest + (*CreateSessionResponse)(nil), // 4: kagent.api.v1.CreateSessionResponse + (*GetSessionRequest)(nil), // 5: kagent.api.v1.GetSessionRequest + (*GetSessionResponse)(nil), // 6: kagent.api.v1.GetSessionResponse + (*ListSessionsRequest)(nil), // 7: kagent.api.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 8: kagent.api.v1.ListSessionsResponse + (*UpdateSessionNameRequest)(nil), // 9: kagent.api.v1.UpdateSessionNameRequest + (*UpdateSessionNameResponse)(nil), // 10: kagent.api.v1.UpdateSessionNameResponse + (*RevertSessionRequest)(nil), // 11: kagent.api.v1.RevertSessionRequest + (*RevertSessionResponse)(nil), // 12: kagent.api.v1.RevertSessionResponse + (*DeleteSessionRequest)(nil), // 13: kagent.api.v1.DeleteSessionRequest + (*DeleteSessionResponse)(nil), // 14: kagent.api.v1.DeleteSessionResponse + (*SessionShare)(nil), // 15: kagent.api.v1.SessionShare + (*CreateSessionShareRequest)(nil), // 16: kagent.api.v1.CreateSessionShareRequest + (*CreateSessionShareResponse)(nil), // 17: kagent.api.v1.CreateSessionShareResponse + (*ListSessionSharesRequest)(nil), // 18: kagent.api.v1.ListSessionSharesRequest + (*ListSessionSharesResponse)(nil), // 19: kagent.api.v1.ListSessionSharesResponse + (*RevokeSessionShareRequest)(nil), // 20: kagent.api.v1.RevokeSessionShareRequest + (*RevokeSessionShareResponse)(nil), // 21: kagent.api.v1.RevokeSessionShareResponse + (RuntimeState)(0), // 22: kagent.api.v1.RuntimeState + (RuntimeOperation)(0), // 23: kagent.api.v1.RuntimeOperation + (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp + (*ResourceReference)(nil), // 25: kagent.api.v1.ResourceReference + (*PageRequest)(nil), // 26: kagent.api.v1.PageRequest + (*PageResponse)(nil), // 27: kagent.api.v1.PageResponse + (*durationpb.Duration)(nil), // 28: google.protobuf.Duration } var file_kagent_api_v1_sessions_proto_depIdxs = []int32{ - 4, // 0: kagent.api.v1.Session.agent:type_name -> kagent.api.v1.AgentProvenance - 24, // 1: kagent.api.v1.Session.state:type_name -> kagent.api.v1.RuntimeState - 25, // 2: kagent.api.v1.Session.operation:type_name -> kagent.api.v1.RuntimeOperation + 2, // 0: kagent.api.v1.Session.agent:type_name -> kagent.api.v1.AgentProvenance + 22, // 1: kagent.api.v1.Session.state:type_name -> kagent.api.v1.RuntimeState + 23, // 2: kagent.api.v1.Session.operation:type_name -> kagent.api.v1.RuntimeOperation 0, // 3: kagent.api.v1.Session.failure:type_name -> kagent.api.v1.Failure - 26, // 4: kagent.api.v1.Session.created_at:type_name -> google.protobuf.Timestamp - 26, // 5: kagent.api.v1.Session.updated_at:type_name -> google.protobuf.Timestamp - 2, // 6: kagent.api.v1.Session.recovery:type_name -> kagent.api.v1.SessionRecovery - 3, // 7: kagent.api.v1.SessionRecovery.failed_messages:type_name -> kagent.api.v1.SessionMessageReference - 27, // 8: kagent.api.v1.AgentProvenance.ref:type_name -> kagent.api.v1.ResourceReference - 27, // 9: kagent.api.v1.CreateSessionRequest.agent:type_name -> kagent.api.v1.ResourceReference - 1, // 10: kagent.api.v1.CreateSessionResponse.session:type_name -> kagent.api.v1.Session - 1, // 11: kagent.api.v1.GetSessionResponse.session:type_name -> kagent.api.v1.Session - 28, // 12: kagent.api.v1.ListSessionsRequest.page:type_name -> kagent.api.v1.PageRequest - 27, // 13: kagent.api.v1.ListSessionsRequest.agent:type_name -> kagent.api.v1.ResourceReference - 1, // 14: kagent.api.v1.ListSessionsResponse.sessions:type_name -> kagent.api.v1.Session - 29, // 15: kagent.api.v1.ListSessionsResponse.page:type_name -> kagent.api.v1.PageResponse - 1, // 16: kagent.api.v1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1.Session - 1, // 17: kagent.api.v1.RevertSessionResponse.session:type_name -> kagent.api.v1.Session - 1, // 18: kagent.api.v1.DeleteSessionResponse.session:type_name -> kagent.api.v1.Session - 26, // 19: kagent.api.v1.SessionShare.created_at:type_name -> google.protobuf.Timestamp - 26, // 20: kagent.api.v1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp - 30, // 21: kagent.api.v1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration - 17, // 22: kagent.api.v1.CreateSessionShareResponse.share:type_name -> kagent.api.v1.SessionShare - 28, // 23: kagent.api.v1.ListSessionSharesRequest.page:type_name -> kagent.api.v1.PageRequest - 17, // 24: kagent.api.v1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1.SessionShare - 29, // 25: kagent.api.v1.ListSessionSharesResponse.page:type_name -> kagent.api.v1.PageResponse - 5, // 26: kagent.api.v1.SessionService.CreateSession:input_type -> kagent.api.v1.CreateSessionRequest - 7, // 27: kagent.api.v1.SessionService.GetSession:input_type -> kagent.api.v1.GetSessionRequest - 9, // 28: kagent.api.v1.SessionService.ListSessions:input_type -> kagent.api.v1.ListSessionsRequest - 11, // 29: kagent.api.v1.SessionService.UpdateSessionName:input_type -> kagent.api.v1.UpdateSessionNameRequest - 13, // 30: kagent.api.v1.SessionService.RevertSession:input_type -> kagent.api.v1.RevertSessionRequest - 15, // 31: kagent.api.v1.SessionService.DeleteSession:input_type -> kagent.api.v1.DeleteSessionRequest - 18, // 32: kagent.api.v1.SessionService.CreateSessionShare:input_type -> kagent.api.v1.CreateSessionShareRequest - 20, // 33: kagent.api.v1.SessionService.ListSessionShares:input_type -> kagent.api.v1.ListSessionSharesRequest - 22, // 34: kagent.api.v1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1.RevokeSessionShareRequest - 6, // 35: kagent.api.v1.SessionService.CreateSession:output_type -> kagent.api.v1.CreateSessionResponse - 8, // 36: kagent.api.v1.SessionService.GetSession:output_type -> kagent.api.v1.GetSessionResponse - 10, // 37: kagent.api.v1.SessionService.ListSessions:output_type -> kagent.api.v1.ListSessionsResponse - 12, // 38: kagent.api.v1.SessionService.UpdateSessionName:output_type -> kagent.api.v1.UpdateSessionNameResponse - 14, // 39: kagent.api.v1.SessionService.RevertSession:output_type -> kagent.api.v1.RevertSessionResponse - 16, // 40: kagent.api.v1.SessionService.DeleteSession:output_type -> kagent.api.v1.DeleteSessionResponse - 19, // 41: kagent.api.v1.SessionService.CreateSessionShare:output_type -> kagent.api.v1.CreateSessionShareResponse - 21, // 42: kagent.api.v1.SessionService.ListSessionShares:output_type -> kagent.api.v1.ListSessionSharesResponse - 23, // 43: kagent.api.v1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1.RevokeSessionShareResponse - 35, // [35:44] is the sub-list for method output_type - 26, // [26:35] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 24, // 4: kagent.api.v1.Session.created_at:type_name -> google.protobuf.Timestamp + 24, // 5: kagent.api.v1.Session.updated_at:type_name -> google.protobuf.Timestamp + 25, // 6: kagent.api.v1.AgentProvenance.ref:type_name -> kagent.api.v1.ResourceReference + 25, // 7: kagent.api.v1.CreateSessionRequest.agent:type_name -> kagent.api.v1.ResourceReference + 1, // 8: kagent.api.v1.CreateSessionResponse.session:type_name -> kagent.api.v1.Session + 1, // 9: kagent.api.v1.GetSessionResponse.session:type_name -> kagent.api.v1.Session + 26, // 10: kagent.api.v1.ListSessionsRequest.page:type_name -> kagent.api.v1.PageRequest + 25, // 11: kagent.api.v1.ListSessionsRequest.agent:type_name -> kagent.api.v1.ResourceReference + 1, // 12: kagent.api.v1.ListSessionsResponse.sessions:type_name -> kagent.api.v1.Session + 27, // 13: kagent.api.v1.ListSessionsResponse.page:type_name -> kagent.api.v1.PageResponse + 1, // 14: kagent.api.v1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1.Session + 1, // 15: kagent.api.v1.RevertSessionResponse.session:type_name -> kagent.api.v1.Session + 1, // 16: kagent.api.v1.DeleteSessionResponse.session:type_name -> kagent.api.v1.Session + 24, // 17: kagent.api.v1.SessionShare.created_at:type_name -> google.protobuf.Timestamp + 24, // 18: kagent.api.v1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp + 28, // 19: kagent.api.v1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration + 15, // 20: kagent.api.v1.CreateSessionShareResponse.share:type_name -> kagent.api.v1.SessionShare + 26, // 21: kagent.api.v1.ListSessionSharesRequest.page:type_name -> kagent.api.v1.PageRequest + 15, // 22: kagent.api.v1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1.SessionShare + 27, // 23: kagent.api.v1.ListSessionSharesResponse.page:type_name -> kagent.api.v1.PageResponse + 3, // 24: kagent.api.v1.SessionService.CreateSession:input_type -> kagent.api.v1.CreateSessionRequest + 5, // 25: kagent.api.v1.SessionService.GetSession:input_type -> kagent.api.v1.GetSessionRequest + 7, // 26: kagent.api.v1.SessionService.ListSessions:input_type -> kagent.api.v1.ListSessionsRequest + 9, // 27: kagent.api.v1.SessionService.UpdateSessionName:input_type -> kagent.api.v1.UpdateSessionNameRequest + 11, // 28: kagent.api.v1.SessionService.RevertSession:input_type -> kagent.api.v1.RevertSessionRequest + 13, // 29: kagent.api.v1.SessionService.DeleteSession:input_type -> kagent.api.v1.DeleteSessionRequest + 16, // 30: kagent.api.v1.SessionService.CreateSessionShare:input_type -> kagent.api.v1.CreateSessionShareRequest + 18, // 31: kagent.api.v1.SessionService.ListSessionShares:input_type -> kagent.api.v1.ListSessionSharesRequest + 20, // 32: kagent.api.v1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1.RevokeSessionShareRequest + 4, // 33: kagent.api.v1.SessionService.CreateSession:output_type -> kagent.api.v1.CreateSessionResponse + 6, // 34: kagent.api.v1.SessionService.GetSession:output_type -> kagent.api.v1.GetSessionResponse + 8, // 35: kagent.api.v1.SessionService.ListSessions:output_type -> kagent.api.v1.ListSessionsResponse + 10, // 36: kagent.api.v1.SessionService.UpdateSessionName:output_type -> kagent.api.v1.UpdateSessionNameResponse + 12, // 37: kagent.api.v1.SessionService.RevertSession:output_type -> kagent.api.v1.RevertSessionResponse + 14, // 38: kagent.api.v1.SessionService.DeleteSession:output_type -> kagent.api.v1.DeleteSessionResponse + 17, // 39: kagent.api.v1.SessionService.CreateSessionShare:output_type -> kagent.api.v1.CreateSessionShareResponse + 19, // 40: kagent.api.v1.SessionService.ListSessionShares:output_type -> kagent.api.v1.ListSessionSharesResponse + 21, // 41: kagent.api.v1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1.RevokeSessionShareResponse + 33, // [33:42] is the sub-list for method output_type + 24, // [24:33] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_kagent_api_v1_sessions_proto_init() } @@ -1553,14 +1433,14 @@ func file_kagent_api_v1_sessions_proto_init() { } file_kagent_api_v1_common_proto_init() file_kagent_api_v1_runtime_proto_init() - file_kagent_api_v1_sessions_proto_msgTypes[2].OneofWrappers = []any{} + file_kagent_api_v1_sessions_proto_msgTypes[1].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1_sessions_proto_rawDesc), len(file_kagent_api_v1_sessions_proto_rawDesc)), NumEnums: 0, - NumMessages: 24, + NumMessages: 22, NumExtensions: 0, NumServices: 1, }, diff --git a/go/core/internal/database/session_recovery.go b/go/core/internal/database/session_recovery.go index 9160648940..d591f33b5b 100644 --- a/go/core/internal/database/session_recovery.go +++ b/go/core/internal/database/session_recovery.go @@ -104,9 +104,6 @@ func (c *Client) BeginCrashedSessionRecovery(ctx context.Context, generation *Se if busy { return ErrConflict } - if err := recordDiscardedSessionMessages(ctx, tx, row, session, snapshot); err != nil { - return err - } row.State = apiv1.RuntimeState_RUNTIME_STATE_FAILED.String() row.runtimeInstanceRow, err = beginRuntimeOperation(ctx, tx, row.runtimeInstanceRow, runtimeKindAgent, apiv1.RuntimeOperation_RUNTIME_OPERATION_REVERT) if err != nil { @@ -131,79 +128,6 @@ func (c *Client) BeginCrashedSessionRecovery(ctx context.Context, generation *Se return operation, err } -// The Session lock keeps annotations atomic with recovery admission and task settlement. -// -// The boundary is the newest history event tagged with the snapshot the Actor -// reverts to. A runtime without a snapshot restarts from its initial state, so -// every input is discarded. A snapshot that no event records leaves the -// boundary unknown: a quiescence whose Substrate call succeeded but whose -// commit did not, or a forked Actor reporting a different URI than its copied -// boundary. Recovery then records no new annotations rather than marking the -// whole transcript lost, because most of that history is usually in the -// snapshot. Annotations from earlier reverts are kept either way. -func recordDiscardedSessionMessages(ctx context.Context, tx pgx.Tx, row sessionRow, session *apiv1.Session, snapshot SessionTaskSnapshot) error { - type messageRow struct { - TaskID string - MessageID string - Data []byte - } - // An empty snapshot URI keeps boundary 0, so every message is discarded. A - // URI that no event records yields a NULL boundary, which selects nothing. - messages, err := queryMany(ctx, tx, ` - WITH boundary AS ( - SELECT CASE WHEN $2::text = '' THEN 0 ELSE MAX(sequence) END AS sequence - FROM session_task_event - WHERE history_id = $1 AND snapshot_uri = $2 - AND snapshot_fidelity = $3 AND snapshot_atespace = $4) - SELECT e.task_id, e.message_id, e.data - FROM session_task_event e, boundary b - WHERE e.history_id = $1 AND e.message_id IS NOT NULL AND e.sequence > b.sequence - ORDER BY e.sequence - `, pgx.RowToStructByName[messageRow], row.HistoryID, snapshot.URI, snapshot.Fidelity, snapshot.Atespace) - if err != nil { - return fmt.Errorf("read discarded Session messages: %w", err) - } - if len(messages) == 0 { - return nil - } - failed := append([]*apiv1.SessionMessageReference(nil), session.GetRecovery().GetFailedMessages()...) - for _, row := range messages { - event, err := unmarshalSessionTaskEvent(row.Data) - if err != nil { - return err - } - message, ok := event.(*a2a.Message) - if !ok || message == nil || row.TaskID == "" || row.MessageID == "" || message.ID != row.MessageID || string(message.TaskID) != row.TaskID || message.ContextID != session.ContextId { - return fmt.Errorf("discarded Session message has inconsistent identity") - } - if message.Role == a2a.MessageRoleUser { - failed = append(failed, &apiv1.SessionMessageReference{TaskId: row.TaskID, MessageId: row.MessageID}) - } - } - if len(failed) == 0 { - return nil - } - type identity struct{ taskID, messageID string } - seen := make(map[identity]struct{}, len(failed)) - unique := make([]*apiv1.SessionMessageReference, 0, len(failed)) - for _, message := range failed { - if message.GetTaskId() == "" || message.GetMessageId() == "" { - return fmt.Errorf("discarded Session message requires a task and message identity") - } - key := identity{message.TaskId, message.MessageId} - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - unique = append(unique, message) - } - if session.Recovery == nil { - session.Recovery = &apiv1.SessionRecovery{} - } - session.Recovery.FailedMessages = unique - return nil -} - // settleCrashedSessionTasks publishes saved results, keeps waits the snapshot // captured, and fails the rest of the open work the revert discards. func settleCrashedSessionTasks(ctx context.Context, tx pgx.Tx, session sessionRow, snapshot SessionTaskSnapshot) error { diff --git a/go/core/internal/database/session_recovery_messages_test.go b/go/core/internal/database/session_recovery_messages_test.go deleted file mode 100644 index 7a574bde2e..0000000000 --- a/go/core/internal/database/session_recovery_messages_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package database - -import ( - "testing" - - "github.com/a2aproject/a2a-go/v2/a2a" - a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" - - apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" -) - -func TestCrashedRecoveryRejectsMalformedMessageHistory(t *testing.T) { - for _, test := range []struct { - name string - data []byte - emptyPayload bool - mutate func(*a2apb.Message) - }{ - {name: "invalid protobuf", data: []byte{0xff}}, - {name: "not a message", emptyPayload: true}, - {name: "message identity", mutate: func(message *a2apb.Message) { - message.MessageId = "different-message" - }}, - {name: "task identity", mutate: func(message *a2apb.Message) { - message.TaskId = "different-task" - }}, - {name: "context identity", mutate: func(message *a2apb.Message) { - message.ContextId = "different-context" - }}, - } { - t.Run(test.name, func(t *testing.T) { - client := NewClient(setupTestDB(t), "public") - sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") - session, task := waitingTaskFixture(t, client) - message := &a2apb.Message{ - MessageId: task.History[0].ID, TaskId: string(task.ID), ContextId: session.ContextId, - Role: a2apb.Role_ROLE_USER, - } - if test.mutate != nil { - test.mutate(message) - } - data := test.data - if data == nil { - event := &a2apb.StreamResponse{} - if !test.emptyPayload { - event.Payload = &a2apb.StreamResponse_Message{Message: message} - } - var err error - data, err = proto.Marshal(event) - require.NoError(t, err) - } - // Ordinary task writes reject these malformed identities before persistence. - _, err := client.db.Exec(t.Context(), ` - UPDATE session_task_event SET data = $2 - WHERE task_id = $1 AND message_id = $3 - `, string(task.ID), data, task.History[0].ID) - require.NoError(t, err) - - _, err = client.BeginCrashedSessionRecovery(t.Context(), observedGeneration(t, client, session.Id, "actor-"+session.Id), SessionTaskSnapshot{}) - require.Error(t, err) - current, err := client.GetSessionByID(t.Context(), session.Id) - require.NoError(t, err) - require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, current.State) - require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, current.Operation) - require.Empty(t, current.GetRecovery().GetFailedMessages()) - require.Nil(t, current.Failure) - }) - } -} - -func TestCrashedRecoveryRollsBackAnnotationsWhenSettlementFails(t *testing.T) { - client := NewClient(setupTestDB(t), "public") - sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") - session, task := waitingTaskFixture(t, client) - _, version, err := client.GetVersionedSessionTask(t.Context(), session.Id, string(task.ID)) - require.NoError(t, err) - for _, state := range []a2a.TaskState{a2a.TaskStateWorking, a2a.TaskStateInputRequired} { - task.Status.State = state - version, err = client.UpdateSessionTask(t.Context(), session.Id, version, taskMutationHash(string(state)), task, task, "") - require.NoError(t, err) - } - require.NoError(t, client.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) - stored, err := readSession(t.Context(), client.db, session.Id) - require.NoError(t, err) - before, err := readSessionTask(t.Context(), client.db, stored.HistoryID, string(task.ID)) - require.NoError(t, err) - // Message history remains valid, so recovery first writes its annotations. - // Corrupting the task projection makes settlement fail later in that transaction. - _, err = client.db.Exec(t.Context(), `UPDATE session_task SET data = $2 WHERE history_id = $1 AND id = $3`, stored.HistoryID, []byte{0xff}, string(task.ID)) - require.NoError(t, err) - _, err = client.BeginCrashedSessionRecovery(t.Context(), observedGeneration(t, client, session.Id, "actor-"+session.Id), SessionTaskSnapshot{}) - require.Error(t, err) - current, err := client.GetSessionByID(t.Context(), session.Id) - require.NoError(t, err) - require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, current.State) - require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, current.Operation) - require.Empty(t, current.GetRecovery().GetFailedMessages()) - - _, err = client.db.Exec(t.Context(), `UPDATE session_task SET data = $2 WHERE history_id = $1 AND id = $3`, stored.HistoryID, before.Data, string(task.ID)) - require.NoError(t, err) - work, err := client.ClaimSessionQuiescence(t.Context()) - require.NoError(t, err, "failed recovery retains the original pending runtime boundary") - require.Equal(t, string(task.ID), work.TaskID) -} - -// A snapshot no history event records leaves the retained boundary unknown, as -// after a quiescence whose commit was lost or in a fork whose Actor reports a -// different URI. Recovery must not then mark the whole transcript as lost. -func TestCrashedRecoveryWithUnrecordedSnapshotAddsNoAnnotations(t *testing.T) { - client := NewClient(setupTestDB(t), "public") - sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") - session, first := waitingTaskFixture(t, client) - actorUID := "actor-" + session.Id - // A runtime without a snapshot restarts from its initial state and loses every input. - completeCrashedRecovery(t, client, session.Id, actorUID) - earlier := []*apiv1.SessionMessageReference{{TaskId: string(first.ID), MessageId: first.History[0].ID}} - current, err := client.GetSessionByID(t.Context(), session.Id) - require.NoError(t, err) - require.True(t, proto.Equal(&apiv1.SessionRecovery{FailedMessages: earlier}, current.GetRecovery())) - - second := newSessionTask(uuid.NewString(), "second") - second.ContextID = session.ContextId - _, err = client.CreateRuntimeTask(t.Context(), session.Id, taskMutationHash("after the first revert"), second, "") - require.NoError(t, err) - operation, err := client.BeginCrashedSessionRecovery(t.Context(), observedGeneration(t, client, session.Id, actorUID), SessionTaskSnapshot{ - Atespace: "team-a", URI: "s3://snapshots/never-recorded", Fidelity: "SNAPSHOT_FIDELITY_VOLUMES", - }) - require.NoError(t, err, "an unrecorded snapshot does not block recovery") - require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_RESUME, operation.Instance.Operation) - finishCrashedRecovery(t, client, session.Id, operation, actorUID) - current, err = client.GetSessionByID(t.Context(), session.Id) - require.NoError(t, err) - require.True(t, proto.Equal(&apiv1.SessionRecovery{FailedMessages: earlier}, current.GetRecovery()), - "an unknown boundary keeps earlier annotations and adds none: got %v", current.GetRecovery()) -} diff --git a/go/core/internal/grpcserver/session_recovery_test.go b/go/core/internal/grpcserver/session_recovery_test.go index a69e3ff128..25d2e64f0c 100644 --- a/go/core/internal/grpcserver/session_recovery_test.go +++ b/go/core/internal/grpcserver/session_recovery_test.go @@ -133,10 +133,10 @@ func TestRevertSessionThroughGRPC(t *testing.T) { t.Helper() response, err := client.GetSession(owner, &apiv1.GetSessionRequest{SessionId: session.Id}) require.NoError(t, err) - require.Equal(t, new(needed), response.Session.GetRecovery().Needed) + require.Equal(t, new(needed), response.Session.RecoveryNeeded) return response.Session } - require.Empty(t, observeRecovery(false).GetRecovery().FailedMessages) + observeRecovery(false) request := &apiv1.RevertSessionRequest{SessionId: session.Id} if test.noSnapshot { control.actors[name].Status.Snapshots = nil @@ -158,7 +158,6 @@ func TestRevertSessionThroughGRPC(t *testing.T) { require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, crashed.State) require.Equal(t, apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, crashed.Operation) require.Nil(t, crashed.Failure) - require.Empty(t, crashed.GetRecovery().FailedMessages) before := proto.CloneOf(control.actors[name]) control.actors[name].Metadata.Uid = "replacement-uid" _, err = client.RevertSession(owner, request) @@ -186,20 +185,13 @@ func TestRevertSessionThroughGRPC(t *testing.T) { failed, err := client.GetSession(owner, &apiv1.GetSessionRequest{SessionId: session.Id}) require.NoError(t, err) require.Equal(t, database.SessionRuntimeCrashed, failed.Session.Failure.Reason) - require.Equal(t, new(true), failed.Session.GetRecovery().Needed) - // No history event records "s3://snapshots/last-good", so only a - // runtime without a snapshot has a known boundary: its initial state. - var wantFailed []*apiv1.SessionMessageReference - if task != nil && test.noSnapshot { - wantFailed = []*apiv1.SessionMessageReference{{TaskId: string(task.ID), MessageId: task.History[0].ID}} - } - require.Equal(t, wantFailed, failed.Session.GetRecovery().FailedMessages) + require.Equal(t, new(true), failed.Session.RecoveryNeeded) ready, err := client.RevertSession(owner, request) require.NoError(t, err) require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, ready.Session.State) require.Nil(t, ready.Session.Failure) - require.Nil(t, ready.Session.Recovery, "only GetSession returns recovery") - require.Equal(t, wantFailed, observeRecovery(false).GetRecovery().FailedMessages) + require.Nil(t, ready.Session.RecoveryNeeded, "only GetSession reports recovery") + observeRecovery(false) // Retry as a client whose success response was lost. retried, err := client.RevertSession(owner, request) require.NoError(t, err) diff --git a/go/core/internal/grpcserver/session_test.go b/go/core/internal/grpcserver/session_test.go index a3ce536631..6182f3889d 100644 --- a/go/core/internal/grpcserver/session_test.go +++ b/go/core/internal/grpcserver/session_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" - "google.golang.org/protobuf/proto" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/core/internal/database" @@ -210,8 +209,8 @@ func TestSessionCreateNamespaceThroughGeneratedClient(t *testing.T) { } // storedRecoveryWorkflow stands in for the Actor workflow in transport tests -// that never reach Substrate. GetSession reports stored recovery annotations -// with needed unknown; lifecycle calls fail, because no test may reach them. +// that never reach Substrate. GetSession reports recovery_needed as unknown; +// lifecycle calls fail, because no test may reach them. type storedRecoveryWorkflow struct{} var errNoRuntimeWorkflow = errors.New("transport test has no runtime workflow") @@ -228,10 +227,6 @@ func (storedRecoveryWorkflow) Delete(context.Context, *apiv1.Session, bool) (*ap return nil, errNoRuntimeWorkflow } -func (storedRecoveryWorkflow) Recovery(_ context.Context, session *apiv1.Session) (*apiv1.SessionRecovery, error) { - recovery := proto.CloneOf(session.GetRecovery()) - if recovery != nil { - recovery.Needed = nil - } - return recovery, nil +func (storedRecoveryWorkflow) RecoveryNeeded(context.Context, *apiv1.Session) (*bool, error) { + return nil, nil } diff --git a/go/core/internal/mcp/tasks_test.go b/go/core/internal/mcp/tasks_test.go index bac157d5e6..1b7c37c11f 100644 --- a/go/core/internal/mcp/tasks_test.go +++ b/go/core/internal/mcp/tasks_test.go @@ -566,7 +566,7 @@ type fakeSessionWorkflow struct{} // Recovery panics because MCP resolves a Session before every invoke and task // call; observing the Actor there would add a Substrate round trip to each one. -func (*fakeSessionWorkflow) Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) { +func (*fakeSessionWorkflow) RecoveryNeeded(context.Context, *apiv1.Session) (*bool, error) { panic("MCP must not observe Session recovery") } diff --git a/go/core/internal/service/session/recovery.go b/go/core/internal/service/session/recovery.go index 39aa8a9af2..fefa809d22 100644 --- a/go/core/internal/service/session/recovery.go +++ b/go/core/internal/service/session/recovery.go @@ -9,7 +9,6 @@ import ( ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/core/internal/database" @@ -20,35 +19,24 @@ import ( // Substrate and database failures must not wrap it, so they stay retryable. var errRuntimeUnrecoverable = fmt.Errorf("runtime cannot be recovered: %w", database.ErrFailedPrecondition) -// Recovery reports whether the Session's runtime needs a revert, from the same -// checks Revert admits one by. A failed observation leaves needed unknown, so a -// control-plane outage can neither offer recovery nor prevent history reads. -func (w *ActorWorkflow) Recovery(ctx context.Context, session *apiv1.Session) (*apiv1.SessionRecovery, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - recovery := proto.CloneOf(session.GetRecovery()) - if recovery == nil { - recovery = &apiv1.SessionRecovery{} - } - recovery.Needed = nil +// RecoveryNeeded returns nil when the observation fails, so a control-plane +// outage can neither offer recovery nor prevent history reads. +func (w *ActorWorkflow) RecoveryNeeded(ctx context.Context, session *apiv1.Session) (*bool, error) { if database.CrashRecoveryAdmitted(session) { - recovery.Needed = new(true) - return recovery, nil + return new(true), nil } if !lifecycleIdle(session) { - recovery.Needed = new(false) - return recovery, nil + return new(false), nil } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() observed, err := w.observeCrash(ctx, session) if err != nil { - return recovery, fmt.Errorf("observe Session recovery: %w", err) + return nil, fmt.Errorf("observe Session recovery: %w", err) } - recovery.Needed = new(observed.crashed()) - return recovery, nil + return new(observed.crashed()), nil } -// lifecycleIdle reports a Session with no lifecycle operation in flight. Only an -// idle Session can admit a new revert; any other operation owns the Actor. func lifecycleIdle(session *apiv1.Session) bool { return session.State == apiv1.RuntimeState_RUNTIME_STATE_READY && session.Operation == apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE } @@ -63,8 +51,8 @@ func (o crashObservation) crashed() bool { } // observeCrash treats a missing, replaced, or retemplated Actor as unrecoverable, -// never as a crash: reverting it would restore the wrong runtime. Recovery and Revert -// share it, so chat offers a revert exactly when Revert would admit one. +// never as a crash: reverting it would restore the wrong runtime. RecoveryNeeded +// and Revert share it, so chat offers a revert exactly when Revert would admit one. // // The generation is read before the Actor. A concurrent revert keeps the Actor UID // and returns to READY, so only the generation shows that an observation predates diff --git a/go/core/internal/service/session/recovery_messages_test.go b/go/core/internal/service/session/recovery_messages_test.go deleted file mode 100644 index 3a9f8bc0f5..0000000000 --- a/go/core/internal/service/session/recovery_messages_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package session - -import ( - "crypto/sha256" - "testing" - "time" - - "github.com/a2aproject/a2a-go/v2/a2a" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - ateapipb "go.ate.dev/substrate/api/ateapi/v1" - - apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" - "github.com/kagent-dev/kagent/go/core/internal/database" - "github.com/kagent-dev/kagent/go/core/internal/substrate" -) - -func TestCrashedRecoveryMarksOnlyDiscardedMessagesInOneTask(t *testing.T) { - store, actors, session, task, version := crashedTurnFixture(t) - task.Status.State = a2a.TaskStateInputRequired - hash := sha256.Sum256([]byte("captured question")) - version, err := store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], task, task, "") - require.NoError(t, err) - require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) - captureRecoveryMessageBoundary(t, store, actors, session) - - answer := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("continue")) - answer.TaskID, answer.ContextID = task.ID, session.ContextId - reply := a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("done")) - reply.TaskID, reply.ContextID = task.ID, session.ContextId - // Repeated history must not move a captured input past the snapshot boundary. - task.History = append(task.History, answer, reply) - task.Status.State = a2a.TaskStateCompleted - hash = sha256.Sum256([]byte("unpublished completed answer")) - _, err = store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], task, task, "") - require.NoError(t, err) - before, _, err := store.GetVersionedSessionTask(t.Context(), session.Id, string(task.ID)) - require.NoError(t, err) - - recovered, err := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute).Revert(t.Context(), session) - require.NoError(t, err) - require.Equal(t, [][2]string{{string(task.ID), answer.ID}}, recoveryMessageIdentities(recovered)) - stored, err := store.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) - require.NoError(t, err) - require.Equal(t, a2a.TaskStateCompleted, stored.Status.State) - require.Equal(t, before.Status, stored.Status) - require.Equal(t, before.History, stored.History, "publishing recovery retains the completed turn's full history") - require.Equal(t, before.Artifacts, stored.Artifacts) - current, err := store.GetSessionByID(t.Context(), session.Id) - require.NoError(t, err) - require.Equal(t, recoveryMessageIdentities(recovered), recoveryMessageIdentities(current), "delivery failures survive a fresh Session read") -} - -func TestCrashedRecoveryScopesMessageFailuresToTheirTask(t *testing.T) { - store, actors, session, retained, version := crashedTurnFixture(t) - retained.Status.State = a2a.TaskStateCompleted - hash := sha256.Sum256([]byte("retained completed turn")) - version, err := store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], retained, retained, "") - require.NoError(t, err) - require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(retained.ID), version)) - captureRecoveryMessageBoundary(t, store, actors, session) - beforeRetained, err := store.GetSettledSessionTask(t.Context(), session.Id, string(retained.ID), nil) - require.NoError(t, err) - - message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("a different turn")) - message.ID, message.ContextID = retained.History[0].ID, session.ContextId - discarded := a2a.NewSubmittedTask(message, message) - discarded.Status.State = a2a.TaskStateCompleted - hash = sha256.Sum256([]byte("completed without a newer snapshot")) - version, err = store.CreateRuntimeTask(t.Context(), session.Id, hash[:], discarded, "") - require.NoError(t, err) - require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(discarded.ID), version)) - beforeDiscarded, err := store.GetSettledSessionTask(t.Context(), session.Id, string(discarded.ID), nil) - require.NoError(t, err) - - workflow := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute) - recovered, err := workflow.Revert(t.Context(), session) - require.NoError(t, err) - want := [][2]string{{string(discarded.ID), message.ID}} - require.Equal(t, want, recoveryMessageIdentities(recovered)) - for _, before := range []*a2a.Task{beforeRetained, beforeDiscarded} { - after, err := store.GetSettledSessionTask(t.Context(), session.Id, string(before.ID), nil) - require.NoError(t, err) - require.Equal(t, before, after, "recovery does not rewrite completed A2A tasks or their history") - } - - // Reverting the same runtime interval must not duplicate delivery failures. - actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED - recovered, err = workflow.Revert(t.Context(), recovered) - require.NoError(t, err) - require.Equal(t, want, recoveryMessageIdentities(recovered)) - - message = a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("a retained new turn")) - message.ContextID = session.ContextId - later := a2a.NewSubmittedTask(message, message) - later.Status.State = a2a.TaskStateCompleted - hash = sha256.Sum256([]byte("completed after recovery")) - version, err = store.CreateRuntimeTask(t.Context(), session.Id, hash[:], later, "") - require.NoError(t, err) - require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(later.ID), version)) - captureRecoveryMessageBoundary(t, store, actors, session) - - message = a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("another lost input")) - message.ContextID = session.ContextId - lost := a2a.NewSubmittedTask(message, message) - hash = sha256.Sum256([]byte("new interrupted turn")) - _, err = store.CreateRuntimeTask(t.Context(), session.Id, hash[:], lost, "") - require.NoError(t, err) - actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED - recovered, err = workflow.Revert(t.Context(), recovered) - require.NoError(t, err) - want = append(want, [2]string{string(lost.ID), message.ID}) - require.Equal(t, want, recoveryMessageIdentities(recovered), "a newer rollback boundary does not erase earlier delivery failures") -} - -func TestCrashedRecoveryWithoutSnapshotMarksAllInputs(t *testing.T) { - store, actors, session, task, _ := crashedTurnFixture(t) - recovered, err := NewActorWorkflow(store, actors, make(chan struct{}, 1), time.Minute).Revert(t.Context(), session) - require.NoError(t, err) - require.Equal(t, [][2]string{{string(task.ID), task.History[0].ID}}, recoveryMessageIdentities(recovered)) -} - -func captureRecoveryMessageBoundary(t *testing.T, store *lifecycleTestStore, actors *lifecycleTestActors, session *apiv1.Session) { - t.Helper() - work, err := store.ClaimSessionQuiescence(t.Context()) - require.NoError(t, err) - require.Equal(t, session.Id, work.Session.Id) - snapshotURI := "s3://snapshots/" + uuid.NewString() - require.NoError(t, store.FinishSessionQuiescence(t.Context(), work, &database.SessionTaskSnapshot{ - Atespace: "team-a", URI: snapshotURI, Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_VOLUMES.String(), - })) - actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.Snapshots = []*ateapipb.Snapshot{{ - Generation: 1, Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_VOLUMES, - DurableSnapshot: &ateapipb.SnapshotStorage{ - State: ateapipb.SnapshotStorageState_SNAPSHOT_STORAGE_STATE_COMPLETED, - Object: &ateapipb.ObjectSnapshot{SnapshotUri: snapshotURI}, - }, - }} -} - -func recoveryMessageIdentities(session *apiv1.Session) [][2]string { - result := make([][2]string, 0, len(session.GetRecovery().GetFailedMessages())) - for _, message := range session.GetRecovery().GetFailedMessages() { - result = append(result, [2]string{message.TaskId, message.MessageId}) - } - return result -} diff --git a/go/core/internal/service/session/recovery_observation_test.go b/go/core/internal/service/session/recovery_observation_test.go index b7a3f8ae84..28937bfcf6 100644 --- a/go/core/internal/service/session/recovery_observation_test.go +++ b/go/core/internal/service/session/recovery_observation_test.go @@ -57,27 +57,22 @@ func TestSessionRecoveryObservation(t *testing.T) { delete(base.actors, key) } actors.readErr = test.readErr - session.Recovery = &apiv1.SessionRecovery{FailedMessages: []*apiv1.SessionMessageReference{{TaskId: "task", MessageId: "message"}}} before := proto.CloneOf(session) - observed, err := workflow.Recovery(t.Context(), session) + needed, err := workflow.RecoveryNeeded(t.Context(), session) if test.want == nil { require.Error(t, err) } else { require.NoError(t, err) } - require.Equal(t, test.want, observed.Needed) - require.Equal(t, session.Recovery.FailedMessages, observed.FailedMessages) - require.True(t, proto.Equal(before, session), "observing recovery must not mutate the Session or its history") + require.Equal(t, test.want, needed) + require.True(t, proto.Equal(before, session), "observing recovery must not mutate the Session") stored, err := store.GetSessionByID(t.Context(), session.Id) require.NoError(t, err) - require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, stored.State) - require.Nil(t, stored.Recovery, "reads do not admit recovery or persist observations") + require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, stored.State, "reads do not admit recovery") // What chat offers is what Revert does: needed admits a revert, a - // healthy runtime is never reverted, and an unknown observation admits - // nothing. A suspended healthy runtime returns the Session unchanged, - // as a retry after a lost revert response does; a running one is - // refused. A missing or replaced Actor is refused for good, so a client + // healthy runtime returns unchanged, and an unknown observation admits + // nothing. A missing or replaced Actor is refused for good, so a client // stops retrying; only a failed read stays retryable. reverted, err := workflow.Revert(t.Context(), session) switch { @@ -101,69 +96,44 @@ func TestSessionRecoveryObservation(t *testing.T) { } } -func TestSessionObservePreservesHistoryWhenRecoveryObservationFails(t *testing.T) { +func TestSessionObserveToleratesUnavailableRuntime(t *testing.T) { id := uuid.NewString() session := &apiv1.Session{Id: id, State: apiv1.RuntimeState_RUNTIME_STATE_READY} - observation := &apiv1.SessionRecovery{FailedMessages: []*apiv1.SessionMessageReference{{TaskId: "task", MessageId: "message"}}} service := NewService(&serviceTestStore{getResult: session}, serviceTestAuthorizer{}, serviceTestWorkflow{ - recovery: observation, recoveryErr: status.Error(codes.Unavailable, "control plane unavailable"), + recoveryErr: status.Error(codes.Unavailable, "control plane unavailable"), }) got, err := service.Observe(serviceTestContext("alice"), id) - require.NoError(t, err) + require.NoError(t, err, "an unavailable runtime does not prevent history reads") require.Equal(t, id, got.Id) - require.Equal(t, observation.FailedMessages, got.Recovery.FailedMessages) - require.Nil(t, got.Recovery.Needed, "an unavailable Actor must not be reported healthy") - require.Nil(t, session.Recovery, "the stored record remains unchanged") + require.Nil(t, got.RecoveryNeeded, "an unavailable Actor must not be reported healthy") } // panicRecoveryWorkflow fails a test that observes Substrate outside GetSession. type panicRecoveryWorkflow struct{ serviceTestWorkflow } -func (panicRecoveryWorkflow) Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) { +func (panicRecoveryWorkflow) RecoveryNeeded(context.Context, *apiv1.Session) (*bool, error) { panic("only GetSession observes recovery") } -// Only GetSession observes Substrate or returns recovery. MCP resolves every -// invoke through Get, and the annotations grow with each lossy revert, so any -// other response carrying them would cost a control-plane round trip or ship -// every conversation's annotations in a list. +// Only GetSession observes Substrate. MCP resolves every invoke through Get, so +// observing there would add a control-plane round trip to each one. func TestSessionRecoveryOnlyInGetSession(t *testing.T) { id := uuid.NewString() - annotations := []*apiv1.SessionMessageReference{{TaskId: "task", MessageId: "message"}} - stored := func() *apiv1.Session { - return &apiv1.Session{ - Id: id, State: apiv1.RuntimeState_RUNTIME_STATE_READY, - Recovery: &apiv1.SessionRecovery{FailedMessages: annotations}, - } - } + stored := &apiv1.Session{Id: id, State: apiv1.RuntimeState_RUNTIME_STATE_READY} ctx := serviceTestContext("alice") - store := &serviceTestStore{getResult: stored(), sessions: []*apiv1.Session{stored()}} + store := &serviceTestStore{getResult: stored, sessions: []*apiv1.Session{stored}} service := NewService(store, serviceTestAuthorizer{}, panicRecoveryWorkflow{}) got, err := service.Get(ctx, id) require.NoError(t, err) - require.Equal(t, annotations, got.GetRecovery().GetFailedMessages(), "Get serves the stored record unchanged") - + require.Nil(t, got.RecoveryNeeded) listed, err := service.List(ctx, ListRequest{}) require.NoError(t, err) require.Len(t, listed.Sessions, 1) - require.Nil(t, listed.Sessions[0].Recovery) - require.NotNil(t, store.sessions[0].Recovery, "omitting recovery does not mutate the stored record") - - reverted, err := service.Revert(ctx, id) - require.NoError(t, err) - require.Nil(t, reverted.Recovery) - deleted, err := service.Delete(ctx, id, false) - require.NoError(t, err) - require.Nil(t, deleted.Recovery) + require.Nil(t, listed.Sessions[0].RecoveryNeeded) - // Observing reports the workflow's result without changing the stored record. - store.getResult = stored() - store.getResult.Recovery.Needed = new(true) - observation := &apiv1.SessionRecovery{FailedMessages: annotations, Needed: new(false)} - observed, err := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{recovery: observation}).Observe(ctx, id) + observed, err := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{recoveryNeeded: new(true)}).Observe(ctx, id) require.NoError(t, err) - require.Equal(t, new(false), observed.Recovery.Needed) - require.Equal(t, annotations, observed.Recovery.FailedMessages) - require.Equal(t, new(true), store.getResult.Recovery.Needed, "observing does not mutate the stored record") + require.Equal(t, new(true), observed.RecoveryNeeded) + require.Nil(t, stored.RecoveryNeeded, "observing does not mutate the stored record") } diff --git a/go/core/internal/service/session/service.go b/go/core/internal/service/session/service.go index 8eddd2d6c0..a539a6c5f6 100644 --- a/go/core/internal/service/session/service.go +++ b/go/core/internal/service/session/service.go @@ -41,7 +41,7 @@ type store interface { type sessionWorkflow interface { Create(context.Context, *apiv1.Session) (*apiv1.Session, error) Revert(context.Context, *apiv1.Session) (*apiv1.Session, error) - Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) + RecoveryNeeded(context.Context, *apiv1.Session) (*bool, error) Delete(context.Context, *apiv1.Session, bool) (*apiv1.Session, error) } @@ -150,7 +150,7 @@ func (s *Service) Get(ctx context.Context, id string) (*apiv1.Session, error) { // Observe reads an authorized Session with its recovery observation. It is the // only Session read that calls Substrate, and GetSession is its only caller: a // slow control plane then delays opening one conversation, not every MCP call. -// A failed observation leaves needed unset rather than failing the read, so an +// A failed observation leaves recovery_needed unset rather than failing the read, so an // unavailable control plane cannot hide the history it would let a user recover. func (s *Service) Observe(ctx context.Context, id string) (*apiv1.Session, error) { session, err := s.getAuthorized(ctx, id, auth.VerbGet) @@ -158,26 +158,13 @@ func (s *Service) Observe(ctx context.Context, id string) (*apiv1.Session, error return nil, err } session = proto.CloneOf(session) - session.Recovery, err = s.workflow.Recovery(ctx, session) + session.RecoveryNeeded, err = s.workflow.RecoveryNeeded(ctx, session) if err != nil { logging.FromContext(ctx).WarnContext(ctx, "observe session recovery", "session_id", session.Id, "error", err) } return session, nil } -// withoutRecovery drops recovery from every response but GetSession. The -// annotations live in the Session record and grow with each revert that -// discards input, so a list response would otherwise carry them for every -// conversation, and every other response would report needed as unknown. -func withoutRecovery(session *apiv1.Session) *apiv1.Session { - if session.GetRecovery() == nil { - return session - } - session = proto.CloneOf(session) - session.Recovery = nil - return session -} - // getAuthorized loads a Session authorized for the requested operation. Reads are // independent of runtime readiness: viewing a suspended conversation must not // provision a worker. Lifecycle and A2A operations use this same access policy. @@ -228,7 +215,7 @@ func (s *Service) Rename(ctx context.Context, id, name string) (*apiv1.Session, if err != nil { return nil, serviceerrors.NewInternal("Failed to rename Session", err) } - return withoutRecovery(session), nil + return session, nil } func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, error) { @@ -252,7 +239,7 @@ func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, er result := ListResult{Sessions: []*apiv1.Session{}} if session.Id > afterID && (request.Agent == nil || proto.Equal(session.GetAgent().GetRef(), request.Agent)) { - result.Sessions = append(result.Sessions, withoutRecovery(session)) + result.Sessions = append(result.Sessions, session) } return result, nil } @@ -285,7 +272,7 @@ func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, er } return ListResult{}, err } - result.Sessions = append(result.Sessions, withoutRecovery(session)) + result.Sessions = append(result.Sessions, session) if len(result.Sessions) > pageSize { result.Sessions = result.Sessions[:pageSize] result.NextPageToken = encodePageToken(result.Sessions[pageSize-1].Id) @@ -313,7 +300,7 @@ func (s *Service) Delete(ctx context.Context, id string, force bool) (*apiv1.Ses if err != nil { return nil, serviceerrors.NewUnavailable("Failed to delete Session", err) } - return withoutRecovery(session), nil + return session, nil } func (s *Service) Revert(ctx context.Context, id string) (*apiv1.Session, error) { @@ -331,7 +318,7 @@ func (s *Service) Revert(ctx context.Context, id string) (*apiv1.Session, error) if err != nil { return nil, serviceerrors.NewUnavailable("Failed to revert Session", err) } - return withoutRecovery(session), nil + return session, nil } // CreateShare mints a read-only share of the caller's session. A positive ttl bounds how long its diff --git a/go/core/internal/service/session/service_test.go b/go/core/internal/service/session/service_test.go index 912fbc00ee..9633b09d16 100644 --- a/go/core/internal/service/session/service_test.go +++ b/go/core/internal/service/session/service_test.go @@ -124,13 +124,13 @@ func (s *serviceTestStore) DeleteSessionShare(_ context.Context, id, _ string) e } type serviceTestWorkflow struct { - err error - recovery *apiv1.SessionRecovery - recoveryErr error + err error + recoveryNeeded *bool + recoveryErr error } -func (w serviceTestWorkflow) Recovery(context.Context, *apiv1.Session) (*apiv1.SessionRecovery, error) { - return w.recovery, w.recoveryErr +func (w serviceTestWorkflow) RecoveryNeeded(context.Context, *apiv1.Session) (*bool, error) { + return w.recoveryNeeded, w.recoveryErr } func (w serviceTestWorkflow) Create(_ context.Context, session *apiv1.Session) (*apiv1.Session, error) { diff --git a/go/core/test/e2e/session_revert_test.go b/go/core/test/e2e/session_revert_test.go index 349c011612..f4d7a6c626 100644 --- a/go/core/test/e2e/session_revert_test.go +++ b/go/core/test/e2e/session_revert_test.go @@ -126,6 +126,9 @@ func TestSessionRevertCrashedRuntime(t *testing.T) { return actor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_CRASHED, err }) require.NoError(t, err) + observed, err := fixture.sessions.GetSession(fixture.ctx, &apiv1.GetSessionRequest{SessionId: fixture.sessionID}) + require.NoError(t, err) + require.True(t, observed.Session.GetRecoveryNeeded()) reverted, err := fixture.sessions.RevertSession(fixture.ctx, request) require.NoError(t, err) require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, reverted.Session.State) diff --git a/proto/kagent/api/v1/sessions.proto b/proto/kagent/api/v1/sessions.proto index 386434a7c4..0140a78feb 100644 --- a/proto/kagent/api/v1/sessions.proto +++ b/proto/kagent/api/v1/sessions.proto @@ -50,25 +50,10 @@ message Session { // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. string context_id = 14; - // Output only. Set only by GetSession; every other response leaves it unset. - SessionRecovery recovery = 16; -} - -message SessionRecovery { - // 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 needed = 1; - // User messages the restored runtime does not remember, from every revert of - // this Session. Each appears once. Their A2A history remains available, - // including completed task results, so the list lasts as long as the - // Session and grows only when a revert discards input. - repeated SessionMessageReference failed_messages = 3; -} - -// Message IDs are unique within a task, not across a conversation. -message SessionMessageReference { - string task_id = 1; - string message_id = 2; + // 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; } message AgentProvenance { diff --git a/ui/src/api/domain/agentInstances.ts b/ui/src/api/domain/agentInstances.ts index 50377a6213..25ec00ecb8 100644 --- a/ui/src/api/domain/agentInstances.ts +++ b/ui/src/api/domain/agentInstances.ts @@ -59,17 +59,6 @@ export type AgentInstanceOperation = | "revert" | "unknown"; -export interface SessionMessageReference { - taskId: string; - messageId: string; -} - -export interface AgentInstanceRecovery { - /** Absent when the controller could not observe the runtime. */ - needed?: boolean; - failedMessages: SessionMessageReference[]; -} - /** * Why an instance failed, as the controller recorded it. * @@ -118,7 +107,8 @@ export interface AgentInstance { operation: AgentInstanceOperation; /** Set only when the controller recorded a failure. */ failure?: AgentInstanceFailure; - recovery?: AgentInstanceRecovery; + /** Set only by GetSession; absent when the controller could not observe the runtime. */ + recoveryNeeded?: boolean; /** RFC3339, or empty when the record carried no timestamp. */ createdAt: string; /** RFC3339, or empty when the record carried no timestamp. */ diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index 4497d315a1..fda0a1b22e 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -605,15 +605,7 @@ function toAgentInstance(instance: PbSession): AgentInstance { message: orUndefined(instance.failure.message), } : undefined, - recovery: instance.recovery - ? { - needed: instance.recovery.needed, - failedMessages: instance.recovery.failedMessages.map((message) => ({ - taskId: message.taskId, - messageId: message.messageId, - })), - } - : undefined, + recoveryNeeded: instance.recoveryNeeded, createdAt: isoFrom(instance.createdAt), updatedAt: isoFrom(instance.updatedAt), }; diff --git a/ui/src/api/operations.test.ts b/ui/src/api/operations.test.ts index 5f9c866222..677c49cac3 100644 --- a/ui/src/api/operations.test.ts +++ b/ui/src/api/operations.test.ts @@ -994,38 +994,35 @@ describe("agent instances", () => { expect(isNotFound(missing)).toBe(true); }); - it("reverts through the existing Session RPC and retains exact failed message references", async () => { + it("reverts through the existing Session RPC", async () => { let received: string | undefined; serve(({ service }) => { service(SessionService, { revertSession: (request) => { received = request.sessionId; - return { session: instanceMessage({ - state: RuntimeState.SUSPENDED, - recovery: { needed: false, failedMessages: [ - { taskId: "completed-task", messageId: "discarded-message" }, - ] }, - }) }; + return { session: instanceMessage({ state: RuntimeState.SUSPENDED }) }; }, }); }); const reverted = await apiClient.agentInstances.revert(INSTANCE_ID); expect(received).toBe(INSTANCE_ID); expect(reverted.state).toBe("suspended"); - expect(reverted.recovery).toEqual({ needed: false, failedMessages: [ - { taskId: "completed-task", messageId: "discarded-message" }, - ] }); + expect(reverted.recoveryNeeded).toBeUndefined(); }); it("keeps an unknown recovery observation distinct from not needing recovery", async () => { serve(({ service }) => { service(SessionService, { - getSession: () => ({ session: instanceMessage({ recovery: {} }) }), + getSession: () => ({ session: instanceMessage({}) }), }); }); - expect((await apiClient.agentInstances.get(INSTANCE_ID)).recovery).toEqual({ - needed: undefined, failedMessages: [], + expect((await apiClient.agentInstances.get(INSTANCE_ID)).recoveryNeeded).toBeUndefined(); + serve(({ service }) => { + service(SessionService, { + getSession: () => ({ session: instanceMessage({ recoveryNeeded: false }) }), + }); }); + expect((await apiClient.agentInstances.get(INSTANCE_ID)).recoveryNeeded).toBe(false); }); }); diff --git a/ui/src/components/chat/ChatMessageItem.tsx b/ui/src/components/chat/ChatMessageItem.tsx index 7a57e0cecb..8e9a0f28df 100644 --- a/ui/src/components/chat/ChatMessageItem.tsx +++ b/ui/src/components/chat/ChatMessageItem.tsx @@ -12,7 +12,6 @@ import { ToolApprovalRecord } from "./ToolApprovalRecord"; import { AskUserRecord } from "./AskUserRecord"; import { AttachmentChip } from "./AttachmentChip"; import { isAwaitingContent, messageText } from "./messageText"; -import type { DiscardedMark } from "./discardedTurns"; const { Text } = Typography; @@ -30,7 +29,6 @@ export function ChatMessageItem({ message, sessionId, isCheckpointed = false, - discarded, onManagePush, }: { message: ChatMessage; @@ -38,8 +36,6 @@ export function ChatMessageItem({ sessionId?: string; /** Whether this message is above the nearest saved boundary, for the browser suite. */ isCheckpointed?: boolean; - /** Whether a runtime recovery discarded this message, and how; see `discardedMessages`. */ - discarded?: DiscardedMark; /** Opens the callback settings for this message's task. */ onManagePush?: () => void; }) { @@ -58,7 +54,6 @@ export function ChatMessageItem({ data-message-id={message.id} data-role={message.role} data-checkpointed={isCheckpointed || undefined} - data-discarded={discarded} css={{ display: "grid", gap: theme.space(2), @@ -77,13 +72,6 @@ export function ChatMessageItem({ {isUser ? "You" : "Agent"} - {/* Labelled in the header rather than only dimmed: the reader message above it - carries the explanation, and a dimmed reply on its own reads as styling. */} - {discarded === "reply" ? ( - - · Not remembered after recovery - - ) : null} {onManagePush ? ( - - )} - /> - ); -} diff --git a/ui/src/components/chat/lifecycleReading.test.ts b/ui/src/components/chat/lifecycleReading.test.ts index 34bcb12996..303a4025f8 100644 --- a/ui/src/components/chat/lifecycleReading.test.ts +++ b/ui/src/components/chat/lifecycleReading.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentInstance } from "@/api"; -import { isLifecycleBusy, lifecycleReading } from "./lifecycleReading"; +import { isLifecycleBusy, isRuntimeLost, lifecycleReading } from "./lifecycleReading"; /** * What the chat page is allowed to say the agent is doing. @@ -42,6 +42,17 @@ describe("lifecycleReading", () => { expect(reading.source).toMatch(/claimed a suspend operation/); }); + it("reads a pending crash recovery from the stored revert operation", () => { + // The Session is FAILED while the recovery is pending; the operation is what says + // the controller is acting on it, so this is a stage rather than a verdict. + const reading = lifecycleReading( + instance({ state: "failed", operation: "revert", failure: { reason: "RuntimeCrashed" } }), + "idle", + ); + expect(reading.active).toBe("resuming"); + expect(reading.summary).toBe("Recovering this conversation after a crash"); + }); + it("lets a claimed operation outrank the turn, because the controller is acting on the instance right now", () => { // Both are true at once — a turn in flight and a suspend being carried out — // and the instance's own lifecycle is the more specific of the two. @@ -144,6 +155,9 @@ describe("isLifecycleBusy", () => { // Two decisions taken separately would drift: a page polling on one rule while // the indicator changes on another either misses a stage or reads for nothing. expect(isLifecycleBusy(instance({ operation: "resume" }), "idle")).toBe(true); + expect(isLifecycleBusy(instance({ state: "failed", operation: "revert" }), "idle")).toBe(true); + // A lost runtime never changes on its own, so it is not polled. + expect(isLifecycleBusy(instance({ state: "failed", failure: { reason: "RuntimeLost" } }), "idle")).toBe(false); expect(isLifecycleBusy(instance(), "streaming")).toBe(true); expect(isLifecycleBusy(instance(), "idle")).toBe(false); expect(isLifecycleBusy(instance({ state: "suspended" }), "idle")).toBe(false); @@ -153,3 +167,14 @@ describe("isLifecycleBusy", () => { expect(isLifecycleBusy(instance(), "idle", true)).toBe(false); }); }); + +describe("isRuntimeLost", () => { + it("is true only for a failed Session with no operation and reason RuntimeLost", () => { + const lost = { state: "failed", operation: "unspecified", failure: { reason: "RuntimeLost" } } as const; + expect(isRuntimeLost(instance(lost))).toBe(true); + expect(isRuntimeLost(instance({ ...lost, operation: "revert" }))).toBe(false); + expect(isRuntimeLost(instance({ ...lost, failure: { reason: "RuntimeCrashed" } }))).toBe(false); + expect(isRuntimeLost(instance({ ...lost, state: "ready" }))).toBe(false); + expect(isRuntimeLost(undefined)).toBe(false); + }); +}); diff --git a/ui/src/components/chat/lifecycleReading.ts b/ui/src/components/chat/lifecycleReading.ts index 35f835f8ae..47f45fc5fe 100644 --- a/ui/src/components/chat/lifecycleReading.ts +++ b/ui/src/components/chat/lifecycleReading.ts @@ -60,6 +60,15 @@ export function lifecycleReading( }; } + if (instance.operation === "revert") { + return { + active: "resuming", + summary: "Recovering this conversation after a crash", + source: + "The agent's runtime crashed and the controller is restoring it from its last snapshot.", + }; + } + if (instance.operation === "suspend") { return { active: "suspending", @@ -167,3 +176,13 @@ export function isLifecycleBusy( ): boolean { return lifecycleReading(instance, turnPhase, isAwaitingReply).active !== undefined; } + +/** + * Whether the conversation's recorded runtime is gone for good, so it can no longer + * answer. It can still be read, forked from a saved snapshot, or deleted. + */ +export function isRuntimeLost(instance: AgentInstance | undefined): boolean { + return instance?.state === "failed" + && instance.operation === "unspecified" + && instance.failure?.reason === "RuntimeLost"; +} diff --git a/ui/src/generated/kagent/api/v1/sessions_pb.ts b/ui/src/generated/kagent/api/v1/sessions_pb.ts index 5b116a0e5d..9df755e0fb 100644 --- a/ui/src/generated/kagent/api/v1/sessions_pb.ts +++ b/ui/src/generated/kagent/api/v1/sessions_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1/sessions.proto. */ export const file_kagent_api_v1_sessions: GenFile = /*@__PURE__*/ - fileDesc("ChxrYWdlbnQvYXBpL3YxL3Nlc3Npb25zLnByb3RvEg1rYWdlbnQuYXBpLnYxIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkixAMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRItCgVhZ2VudBgPIAEoCzIeLmthZ2VudC5hcGkudjEuQWdlbnRQcm92ZW5hbmNlEhkKEXByZXBhcmVkX3JldmlzaW9uGAUgASgJEhUKDWEyYV9hdXRob3JpdHkYBiABKAkSKgoFc3RhdGUYByABKA4yGy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVTdGF0ZRIyCglvcGVyYXRpb24YCCABKA4yHy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVPcGVyYXRpb24SJwoHZmFpbHVyZRgJIAEoCzIWLmthZ2VudC5hcGkudjEuRmFpbHVyZRIuCgpjcmVhdGVkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRuYW1lGA0gASgJEhIKCmNvbnRleHRfaWQYDiABKAkSHAoPcmVjb3ZlcnlfbmVlZGVkGBAgASgISACIAQFCEgoQX3JlY292ZXJ5X25lZWRlZCJNCg9BZ2VudFByb3ZlbmFuY2USLQoDcmVmGAEgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZRILCgN1aWQYAiABKAkivAEKFENyZWF0ZVNlc3Npb25SZXF1ZXN0EjcKBWFnZW50GAUgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZUIGukgDyAEBEh4KCnJlcXVlc3RfaWQYAyABKAlCCrpIB3IFEAEYgAESSwoEbmFtZRgEIAEoCUI9ukg6cjgYyAEyM14oPzokfFteXHB7Wn1ccHtDY31dKD86W15ccHtDY31dKlteXHB7Wn1ccHtDY31dKT8pJCJAChVDcmVhdGVTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiIxChFHZXRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASI9ChJHZXRTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiKGAQoTTGlzdFNlc3Npb25zUmVxdWVzdBIUCgxhbGxfY3JlYXRvcnMYAiABKAgSKAoEcGFnZRgDIAEoCzIaLmthZ2VudC5hcGkudjEuUGFnZVJlcXVlc3QSLwoFYWdlbnQYBiABKAsyIC5rYWdlbnQuYXBpLnYxLlJlc291cmNlUmVmZXJlbmNlImsKFExpc3RTZXNzaW9uc1Jlc3BvbnNlEigKCHNlc3Npb25zGAEgAygLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uEikKBHBhZ2UYAiABKAsyGy5rYWdlbnQuYXBpLnYxLlBhZ2VSZXNwb25zZSKFAQoYVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEksKBG5hbWUYAiABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiRAoZVXBkYXRlU2Vzc2lvbk5hbWVSZXNwb25zZRInCgdzZXNzaW9uGAEgASgLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uIjQKFFJldmVydFNlc3Npb25SZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBIkAKFVJldmVydFNlc3Npb25SZXNwb25zZRInCgdzZXNzaW9uGAEgASgLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uIkMKFERlbGV0ZVNlc3Npb25SZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEg0KBWZvcmNlGAIgASgIIkAKFURlbGV0ZVNlc3Npb25SZXNwb25zZRInCgdzZXNzaW9uGAEgASgLMhYua2FnZW50LmFwaS52MS5TZXNzaW9uIo4BCgxTZXNzaW9uU2hhcmUSCgoCaWQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIuCgpjcmVhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJrChlDcmVhdGVTZXNzaW9uU2hhcmVSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEjAKA3R0bBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkIIukgFqgECKgAiVwoaQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2USKgoFc2hhcmUYASABKAsyGy5rYWdlbnQuYXBpLnYxLlNlc3Npb25TaGFyZRINCgV0b2tlbhgCIAEoCSJiChhMaXN0U2Vzc2lvblNoYXJlc1JlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESKAoEcGFnZRgCIAEoCzIaLmthZ2VudC5hcGkudjEuUGFnZVJlcXVlc3QicwoZTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRIrCgZzaGFyZXMYASADKAsyGy5rYWdlbnQuYXBpLnYxLlNlc3Npb25TaGFyZRIpCgRwYWdlGAIgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2UiNwoZUmV2b2tlU2Vzc2lvblNoYXJlUmVxdWVzdBIaCghzaGFyZV9pZBgBIAEoCUIIukgFcgOwAQEiHAoaUmV2b2tlU2Vzc2lvblNoYXJlUmVzcG9uc2Uy9gYKDlNlc3Npb25TZXJ2aWNlEloKDUNyZWF0ZVNlc3Npb24SIy5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25SZXF1ZXN0GiQua2FnZW50LmFwaS52MS5DcmVhdGVTZXNzaW9uUmVzcG9uc2USUQoKR2V0U2Vzc2lvbhIgLmthZ2VudC5hcGkudjEuR2V0U2Vzc2lvblJlcXVlc3QaIS5rYWdlbnQuYXBpLnYxLkdldFNlc3Npb25SZXNwb25zZRJXCgxMaXN0U2Vzc2lvbnMSIi5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uc1JlcXVlc3QaIy5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uc1Jlc3BvbnNlEmYKEVVwZGF0ZVNlc3Npb25OYW1lEicua2FnZW50LmFwaS52MS5VcGRhdGVTZXNzaW9uTmFtZVJlcXVlc3QaKC5rYWdlbnQuYXBpLnYxLlVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USWgoNUmV2ZXJ0U2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuUmV2ZXJ0U2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLlJldmVydFNlc3Npb25SZXNwb25zZRJaCg1EZWxldGVTZXNzaW9uEiMua2FnZW50LmFwaS52MS5EZWxldGVTZXNzaW9uUmVxdWVzdBokLmthZ2VudC5hcGkudjEuRGVsZXRlU2Vzc2lvblJlc3BvbnNlEmkKEkNyZWF0ZVNlc3Npb25TaGFyZRIoLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBopLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2USZgoRTGlzdFNlc3Npb25TaGFyZXMSJy5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uU2hhcmVzUmVxdWVzdBooLmthZ2VudC5hcGkudjEuTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRJpChJSZXZva2VTZXNzaW9uU2hhcmUSKC5rYWdlbnQuYXBpLnYxLlJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QaKS5rYWdlbnQuYXBpLnYxLlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlQj1aO2dpdGh1Yi5jb20va2FnZW50LWRldi9rYWdlbnQvZ28vYXBpL2dlbi9rYWdlbnQvYXBpL3YxO2FwaXYxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1_common, file_kagent_api_v1_runtime]); + fileDesc("ChxrYWdlbnQvYXBpL3YxL3Nlc3Npb25zLnByb3RvEg1rYWdlbnQuYXBpLnYxIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkikgMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRItCgVhZ2VudBgPIAEoCzIeLmthZ2VudC5hcGkudjEuQWdlbnRQcm92ZW5hbmNlEhkKEXByZXBhcmVkX3JldmlzaW9uGAUgASgJEhUKDWEyYV9hdXRob3JpdHkYBiABKAkSKgoFc3RhdGUYByABKA4yGy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVTdGF0ZRIyCglvcGVyYXRpb24YCCABKA4yHy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVPcGVyYXRpb24SJwoHZmFpbHVyZRgJIAEoCzIWLmthZ2VudC5hcGkudjEuRmFpbHVyZRIuCgpjcmVhdGVkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRuYW1lGA0gASgJEhIKCmNvbnRleHRfaWQYDiABKAkiTQoPQWdlbnRQcm92ZW5hbmNlEi0KA3JlZhgBIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2USCwoDdWlkGAIgASgJIrwBChRDcmVhdGVTZXNzaW9uUmVxdWVzdBI3CgVhZ2VudBgFIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2VCBrpIA8gBARIeCgpyZXF1ZXN0X2lkGAMgASgJQgq6SAdyBRABGIABEksKBG5hbWUYBCABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiQAoVQ3JlYXRlU2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24iMQoRR2V0U2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiPQoSR2V0U2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24ihgEKE0xpc3RTZXNzaW9uc1JlcXVlc3QSFAoMYWxsX2NyZWF0b3JzGAIgASgIEigKBHBhZ2UYAyABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0Ei8KBWFnZW50GAYgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZSJrChRMaXN0U2Vzc2lvbnNSZXNwb25zZRIoCghzZXNzaW9ucxgBIAMoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbhIpCgRwYWdlGAIgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2UihQEKGFVwZGF0ZVNlc3Npb25OYW1lUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARJLCgRuYW1lGAIgASgJQj26SDpyOBjIATIzXig/OiR8W15ccHtafVxwe0NjfV0oPzpbXlxwe0NjfV0qW15ccHtafVxwe0NjfV0pPykkIkQKGVVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiI0ChRSZXZlcnRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJAChVSZXZlcnRTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiJDChREZWxldGVTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARINCgVmb3JjZRgCIAEoCCJAChVEZWxldGVTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiKOAQoMU2Vzc2lvblNoYXJlEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSLgoKY3JlYXRlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKZXhwaXJlc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiawoZQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARIwCgN0dGwYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CCLpIBaoBAioAIlcKGkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEioKBXNoYXJlGAEgASgLMhsua2FnZW50LmFwaS52MS5TZXNzaW9uU2hhcmUSDQoFdG9rZW4YAiABKAkiYgoYTGlzdFNlc3Npb25TaGFyZXNSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEigKBHBhZ2UYAiABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0InMKGUxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USKwoGc2hhcmVzGAEgAygLMhsua2FnZW50LmFwaS52MS5TZXNzaW9uU2hhcmUSKQoEcGFnZRgCIAEoCzIbLmthZ2VudC5hcGkudjEuUGFnZVJlc3BvbnNlIjcKGVJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QSGgoIc2hhcmVfaWQYASABKAlCCLpIBXIDsAEBIhwKGlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlMvYGCg5TZXNzaW9uU2VydmljZRJaCg1DcmVhdGVTZXNzaW9uEiMua2FnZW50LmFwaS52MS5DcmVhdGVTZXNzaW9uUmVxdWVzdBokLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblJlc3BvbnNlElEKCkdldFNlc3Npb24SIC5rYWdlbnQuYXBpLnYxLkdldFNlc3Npb25SZXF1ZXN0GiEua2FnZW50LmFwaS52MS5HZXRTZXNzaW9uUmVzcG9uc2USVwoMTGlzdFNlc3Npb25zEiIua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvbnNSZXF1ZXN0GiMua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvbnNSZXNwb25zZRJmChFVcGRhdGVTZXNzaW9uTmFtZRInLmthZ2VudC5hcGkudjEuVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0Gigua2FnZW50LmFwaS52MS5VcGRhdGVTZXNzaW9uTmFtZVJlc3BvbnNlEloKDVJldmVydFNlc3Npb24SIy5rYWdlbnQuYXBpLnYxLlJldmVydFNlc3Npb25SZXF1ZXN0GiQua2FnZW50LmFwaS52MS5SZXZlcnRTZXNzaW9uUmVzcG9uc2USWgoNRGVsZXRlU2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuRGVsZXRlU2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLkRlbGV0ZVNlc3Npb25SZXNwb25zZRJpChJDcmVhdGVTZXNzaW9uU2hhcmUSKC5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25TaGFyZVJlcXVlc3QaKS5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEmYKEUxpc3RTZXNzaW9uU2hhcmVzEicua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvblNoYXJlc1JlcXVlc3QaKC5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USaQoSUmV2b2tlU2Vzc2lvblNoYXJlEigua2FnZW50LmFwaS52MS5SZXZva2VTZXNzaW9uU2hhcmVSZXF1ZXN0Gikua2FnZW50LmFwaS52MS5SZXZva2VTZXNzaW9uU2hhcmVSZXNwb25zZUI9WjtnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MTthcGl2MWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1_common, file_kagent_api_v1_runtime]); /** * @generated from message kagent.api.v1.Failure @@ -83,6 +83,10 @@ export type Session = Message<"kagent.api.v1.Session"> & { operation: RuntimeOperation; /** + * Output only. With state FAILED, reason RuntimeCrashed and operation REVERT + * mean a crash recovery is pending; reason RuntimeLost and operation NONE mean + * the recorded runtime is gone and the Session can only be deleted. + * * @generated from field: kagent.api.v1.Failure failure = 9; */ failure?: Failure | undefined; @@ -111,15 +115,6 @@ export type Session = Message<"kagent.api.v1.Session"> & { * @generated from field: string context_id = 14; */ contextId: string; - - /** - * 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. - * - * @generated from field: optional bool recovery_needed = 16; - */ - recoveryNeeded?: boolean | undefined; }; /** diff --git a/ui/src/mocks/mockBackend.test.ts b/ui/src/mocks/mockBackend.test.ts index 842f02bf99..2d0cefebcf 100644 --- a/ui/src/mocks/mockBackend.test.ts +++ b/ui/src/mocks/mockBackend.test.ts @@ -108,7 +108,6 @@ const INPUTS = { id: "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44", }, - "agentInstances.revert": { id: "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44" }, "agentInstances.shares.list": { id: "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44", diff --git a/ui/src/mocks/transport.ts b/ui/src/mocks/transport.ts index 1f49c40109..a944067b96 100644 --- a/ui/src/mocks/transport.ts +++ b/ui/src/mocks/transport.ts @@ -657,7 +657,6 @@ function agentInstanceMessage( // turns it back into "not reported". preparedRevision: row.preparedRevision ?? "", a2aAuthority: row.a2aAuthority ?? "", - recoveryNeeded: row.recoveryNeeded, state: PB_STATE_BY_NAME[row.state], operation: PB_OPERATION_BY_NAME[row.operation], failure: row.failure @@ -839,11 +838,6 @@ on(SessionService.method.getSession, (input, call) => ({ ), })); -// Fixture runtimes never crash, so a revert returns the Session unchanged. -on(SessionService.method.revertSession, (input, call) => ({ - session: agentInstanceMessage(instanceFor(requireInstanceId(input.sessionId), call)), -})); - on(SessionService.method.createSession, (input, call) => { const namespace = requireNamespace(input.agent?.namespace ?? ""); if (!input.agent?.name.trim()) throw new ConnectError("an Agent is required", Code.InvalidArgument); diff --git a/ui/src/pages/AgentChatPage.recovery.test.tsx b/ui/src/pages/AgentChatPage.recovery.test.tsx index 046a946719..680c679d5d 100644 --- a/ui/src/pages/AgentChatPage.recovery.test.tsx +++ b/ui/src/pages/AgentChatPage.recovery.test.tsx @@ -1,19 +1,23 @@ import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { ThemeProvider } from "@emotion/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { SWRConfig } from "swr"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect"; -import toast from "react-hot-toast"; +import { createRouterTransport } from "@connectrpc/connect"; import { SessionService } from "@/generated/kagent/api/v1/sessions_pb"; -import { CheckpointService } from "@/generated/kagent/api/v1/checkpoints_pb"; +import { CheckpointService, CheckpointState } from "@/generated/kagent/api/v1/checkpoints_pb"; import { RuntimeOperation, RuntimeState } from "@/generated/kagent/api/v1/runtime_pb"; import { setApiTransport } from "@/api/transport"; import type { ChatController } from "@/api"; import { themeFor } from "@/theme/theme"; import { AgentChatPage } from "./AgentChatPage"; +/* + * Crash recovery as the chat page shows it: read from the stored Session alone. + * A send that finds a crashed runtime recovers it on the server; the page only says + * what the stored state already records. + */ + // Only the A2A chat controller is faked; Session reads go through the real hooks and client. const chat = { messages: [], @@ -36,48 +40,53 @@ vi.mock("@/api", async (importOriginal) => ({ })); const ID = "6f1c9d20-1b7a-4a1e-9a3f-2c0d8e5b1a44"; -const LIVE_REFRESH_MS = 4_000; - - -let lifecycle: { state: RuntimeState; operation: RuntimeOperation }; +const LIFECYCLE_POLL_MS = 1_000; + +type Lifecycle = { + state: RuntimeState; + operation: RuntimeOperation; + failure?: { reason: string; message: string }; +}; + +const READY: Lifecycle = { state: RuntimeState.READY, operation: RuntimeOperation.NONE }; +const RECOVERING: Lifecycle = { + state: RuntimeState.FAILED, + operation: RuntimeOperation.REVERT, + failure: { reason: "RuntimeCrashed", message: "runtime crashed" }, +}; +const LOST: Lifecycle = { + state: RuntimeState.FAILED, + operation: RuntimeOperation.NONE, + failure: { reason: "RuntimeLost", message: "The Session runtime no longer exists." }, +}; + +let lifecycle: Lifecycle; +let checkpointState: CheckpointState | undefined; +let getSession: ReturnType; -function session(recoveryNeeded?: boolean) { +function session() { return { id: ID, contextId: ID, creator: "alice@example.com", agent: { ref: { namespace: "kagent", name: "assistant" }, uid: "agent-uid" }, ...lifecycle, - recoveryNeeded, }; } -const HEALTHY = false; -const CRASHED = true; -const IDLE = { state: RuntimeState.READY, operation: RuntimeOperation.NONE }; -/** An earlier attempt admitted the recovery, then failed; it stays pending until a retry. */ -const ADMITTED = { state: RuntimeState.FAILED, operation: RuntimeOperation.REVERT }; - -let backend: boolean; -let getSession: ReturnType; -let revertSession: ReturnType; -function recovered() { - backend = HEALTHY; - lifecycle = IDLE; - return { session: session(backend) }; -} - -type Reverted = ReturnType; -let revert: () => Reverted | Promise; - function renderPage() { setApiTransport(createRouterTransport(({ service }) => { service(SessionService, { - getSession: (getSession = vi.fn(() => ({ session: session(backend) }))), - listSessions: () => ({ sessions: [session(backend)] }), - revertSession: (revertSession = vi.fn(() => revert())), + getSession: (getSession = vi.fn(() => ({ session: session() }))), + listSessions: () => ({ sessions: [session()] }), + }); + service(CheckpointService, { + listCheckpoints: () => ({ + checkpoints: checkpointState === undefined + ? [] + : [{ id: "checkpoint-1", sessionId: ID, headTaskId: "task-1", state: checkpointState }], + }), }); - service(CheckpointService, { listCheckpoints: () => ({ checkpoints: [] }) }); })); render( @@ -104,9 +113,7 @@ beforeEach(() => { // Only intervals are faked: the page's polls are intervals, and SWR and the // transport keep their real timeouts and promises. vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); - vi.mocked(chat.refreshTranscript).mockClear(); - lifecycle = IDLE; - revert = recovered; + checkpointState = undefined; }); afterEach(() => { @@ -115,88 +122,45 @@ afterEach(() => { setApiTransport(undefined); }); -describe("a crash found on an open conversation", () => { - it("finds a crash that happens after the page went idle", async () => { - backend = HEALTHY; +describe("a crash recovery in progress", () => { + it("says the conversation is recovering and enables the composer once it is ready", async () => { + lifecycle = RECOVERING; renderPage(); - await waitFor(() => expect(composer()).toBeEnabled()); - expect(screen.queryByText("The agent runtime crashed")).not.toBeInTheDocument(); - - // The Actor crashes with no turn running, so nothing on the page is polling closely. - backend = CRASHED; - await tick(LIVE_REFRESH_MS); - - expect(await screen.findByText("The agent runtime crashed")).toBeInTheDocument(); + expect(await screen.findByText("Recovering this conversation after a crash")).toBeInTheDocument(); expect(composer()).toBeDisabled(); - }); - it("does not re-read a crashed Session every second while it waits on the reader", async () => { - backend = CRASHED; - renderPage(); - await screen.findByText("The agent runtime crashed"); - await waitFor(() => expect(composer()).toBeDisabled()); - - const before = getSession.mock.calls.length; - await tick(LIVE_REFRESH_MS - 1); - expect(getSession.mock.calls.length).toBe(before); - await tick(1); - await waitFor(() => expect(getSession.mock.calls.length).toBe(before + 1)); - }); - - it("reverts, then re-reads the Session and the transcript and enables the composer", async () => { - backend = CRASHED; - renderPage(); - await screen.findByText("The agent runtime crashed"); - expect(composer()).toBeDisabled(); + // The stored revert operation keeps the lifecycle poll running. + lifecycle = READY; const reads = getSession.mock.calls.length; - - await userEvent.click(screen.getByRole("button", { name: "Revert to last snapshot" })); + await tick(LIFECYCLE_POLL_MS); + await waitFor(() => expect(getSession.mock.calls.length).toBeGreaterThan(reads)); await waitFor(() => expect(composer()).toBeEnabled()); - expect(revertSession).toHaveBeenCalledOnce(); - expect(getSession.mock.calls.length).toBeGreaterThan(reads); - expect(chat.refreshTranscript).toHaveBeenCalled(); - expect(screen.queryByText("The agent runtime crashed")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chat-not-ready")).not.toBeInTheDocument(); }); }); -describe("a recovery admitted by a failed attempt", () => { - const revertButton = () => screen.getByRole("button", { name: /Revert to last snapshot/ }); - - it("keeps Revert usable, and a click joins the admitted recovery", async () => { - backend = CRASHED; - lifecycle = ADMITTED; +describe("a runtime that cannot be recovered", () => { + it("says so, keeps the composer disabled, and does not poll a state that never changes", async () => { + lifecycle = LOST; renderPage(); - await screen.findByText("Recovering this conversation"); - await waitFor(() => expect(revertButton()).toBeEnabled()); - - await userEvent.click(revertButton()); + const alert = await screen.findByTestId("chat-not-ready"); + expect(alert).toHaveTextContent("This conversation's runtime cannot be recovered"); + expect(alert).toHaveTextContent("The Session runtime no longer exists."); + expect(alert).toHaveTextContent("Start another conversation. This one can still be read."); + expect(composer()).toBeDisabled(); - await waitFor(() => expect(screen.queryByText("Recovering this conversation")).not.toBeInTheDocument()); - expect(revertSession).toHaveBeenCalledOnce(); - expect(chat.refreshTranscript).toHaveBeenCalled(); + const reads = getSession.mock.calls.length; + await tick(LIFECYCLE_POLL_MS * 3); + expect(getSession.mock.calls.length).toBe(reads); }); - it("leaves Revert usable when other work refuses it", async () => { - const error = vi.spyOn(toast, "error"); - let refuse!: () => void; - revert = () => new Promise((_, reject) => { - refuse = () => reject(new ConnectError("lifecycle operation is pending", Code.Aborted)); - }); - backend = CRASHED; - lifecycle = ADMITTED; + it("offers forking only when a saved snapshot can fork", async () => { + lifecycle = LOST; + checkpointState = CheckpointState.READY; renderPage(); - await screen.findByText("Recovering this conversation"); - await waitFor(() => expect(revertButton()).toBeEnabled()); - - act(() => revertButton().click()); - await waitFor(() => expect(revertButton()).toBeDisabled()); - expect(revertSession).toHaveBeenCalledOnce(); - - await act(async () => refuse()); - - await waitFor(() => expect(revertButton()).toBeEnabled()); - expect(error).toHaveBeenCalledWith(expect.stringContaining("Could not recover the conversation")); - expect(screen.getByText("Recovering this conversation")).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId("chat-not-ready")).toHaveTextContent("fork a saved snapshot from the transcript"), + ); }); }); diff --git a/ui/src/pages/AgentChatPage.tsx b/ui/src/pages/AgentChatPage.tsx index 7d7062508f..819d78c15f 100644 --- a/ui/src/pages/AgentChatPage.tsx +++ b/ui/src/pages/AgentChatPage.tsx @@ -13,12 +13,12 @@ import { ConversationDetailsModal } from "@/components/chat/ConversationDetailsM import { SnapshotDetailsModal } from "@/components/chat/SnapshotDetailsModal"; import { SnapshotRenameDialog } from "@/components/chat/SnapshotRenameDialog"; import { ChatTranscript } from "@/components/chat/ChatTranscript"; -import { SessionRecoveryAlert } from "@/components/chat/SessionRecoveryAlert"; import { TaskPushNotificationsModal } from "@/components/chat/TaskPushNotificationsModal"; -import { isLifecycleBusy } from "@/components/chat/lifecycleReading"; +import { isLifecycleBusy, isRuntimeLost } from "@/components/chat/lifecycleReading"; import { paths } from "@/router/routes"; import { apiClient, + canForkFrom, useAgentInstance, useAgentInstances, useAgentTakesFiles, @@ -28,7 +28,7 @@ import { autoTitleFrom } from "@/components/agent-instances/instanceLabels"; import { useLiveTranscript } from "@/api/hooks/useLiveTranscript"; import { useInvalidateConversations } from "@/api/hooks/useInvalidateConversations"; import { useCheckpoints } from "@/api/hooks/useCheckpoints"; -import type { Checkpoint } from "@/api"; +import type { AgentInstance, Checkpoint } from "@/api"; import { useCollapsedBelow } from "@/components/chat/useNarrowViewport"; import { checkpointsByMessage } from "@/components/chat/messageCheckpoints"; import { messageSummary } from "@/components/chat/messageText"; @@ -37,6 +37,42 @@ import { useExtensionAgentLinks } from "@/appExtensions/hooks"; import { agentUrl } from "@/components/agent/agentUrl"; import { isMockMode } from "@/api/config"; +/** + * What the not-ready alert says about a conversation that cannot take a message. + * + * 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( + instance: AgentInstance, + canFork: boolean, +): { title: string; description: string } { + if (isRuntimeLost(instance)) { + return { + title: "This conversation's runtime cannot be recovered", + description: [ + instance.failure?.message, + canFork + ? "Start another conversation, or fork a saved snapshot from the transcript. This one can still be read." + : "Start another conversation. This one can still be read.", + ].filter(Boolean).join(" "), + }; + } + if (instance.operation === "revert") { + return { + title: "Recovering this conversation after a crash", + description: + "The agent's runtime crashed and is being restored from its last snapshot. The agent accepts messages again once that finishes, and does not remember messages sent after that snapshot.", + }; + } + return { + title: `This conversation is ${instance.state}, so the agent cannot answer`, + description: + instance.failure?.message || + "The agent accepts messages once this conversation is ready. You can also start another conversation.", + }; +} + /** * How often the instance is re-read while it is doing something. * @@ -115,15 +151,8 @@ export function AgentChatPage() { const chat = useChat(conversation); const [pushTask, setPushTask] = useState<{ conversationId: string; taskId: string }>(); - /* - * Keep up with messages sent from other tabs or clients, and with the Session itself: - * the Actor can crash during automatic suspension, after the lifecycle poll below has - * stopped, and only a Session read shows that. Riding this refresh avoids widening the - * one-second lifecycle poll to every idle page. - */ - useLiveTranscript(async () => { - await Promise.allSettled([chat.refreshTranscript(), instance.refresh()]); - }, { + // Keep up with messages sent from other tabs or clients. + useLiveTranscript(chat.refreshTranscript, { enabled: Boolean(conversation), isBusy: chat.phase === "streaming", }); @@ -313,8 +342,7 @@ export function AgentChatPage() { // Automatic runtime quiescence leaves the conversation ready for its next turn. const state = instance.data?.state; - const recoveryNeeded = instance.data?.recoveryNeeded === true; - const canSend = state === "ready" && !recoveryNeeded; + const canSend = state === "ready"; /* * Watching the instance while it is doing something. @@ -436,37 +464,7 @@ export function AgentChatPage() { // and depending on it would re-run this on each one. // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, chat.isLoadingHistory]); - /* - * Only this page's own request disables Revert: an admitted recovery keeps its - * operation through every failed attempt, and a revert sent meanwhile joins it. - */ - const [isReverting, setReverting] = useState(false); - const revertRuntime = useCallback(async () => { - if (!id) return; - setReverting(true); - let reverted = false; - try { - await apiClient.agentInstances.revert(id); - reverted = true; - await instance.refresh(); - await chat.refreshTranscript(); - } catch (cause: unknown) { - const reason = cause instanceof Error ? cause.message : String(cause); - toast.error(reverted - ? `Recovery completed. Could not refresh the conversation: ${reason}` - : `Could not recover the conversation: ${reason}`); - } finally { - setReverting(false); - } - }, [id, instance, chat]); - - /* - * `recoveryNeeded` alone does not keep the one-second poll running: every read of a - * crashed Session also queries Substrate, and an open page would do that until someone - * acted. The live refresh above still notices a recovery from another tab or client. - */ - const isBusy = isLifecycleBusy(instance.data, chat.turnPhase, isAwaitingReply) - || isReverting; + const isBusy = isLifecycleBusy(instance.data, chat.turnPhase, isAwaitingReply); /* * The conversation list is deliberately not re-read when this one's state changes. @@ -659,24 +657,12 @@ export function AgentChatPage() { /> ) : null} - - - {instance.data && !canSend && !recoveryNeeded ? ( + {instance.data && !canSend ? ( ) : null} @@ -686,8 +672,6 @@ export function AgentChatPage() { chat={chat} isLoading={instance.isLoading} sessionId={id} - recoveryNeeded={recoveryNeeded} - readOnly={recoveryNeeded} onOpenCheckpoint={openSnapshot} onRenameCheckpoint={renameSnapshot} onFork={forkCheckpoint} From 9e269080916099f9432b3e21d4b41709e9e35046 Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Mon, 9 Nov 2026 14:34:03 -0800 Subject: [PATCH 12/12] feat(session): make recovery on send opt-in 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 --- .github/workflows/ci.yaml | 2 + docs/architecture/process-configuration.md | 5 +++ go/api/gen/kagent/config/v1/config.pb.go | 14 ++++++- go/core/internal/a2agateway/routing_test.go | 41 ++++++++++++++++++- .../scheduledrun/controller_test.go | 2 +- .../scheduledrun_controller_test.go | 2 +- .../internal/grpcserver/task_store_test.go | 4 +- .../service/session/expiration_test.go | 2 +- .../internal/service/session/interactions.go | 26 +++++++----- .../service/session/interactions_test.go | 14 +++---- .../internal/service/session/recovery_test.go | 4 +- go/core/pkg/app/app.go | 2 +- go/core/pkg/config/helm_test.go | 5 +++ go/core/pkg/config/load_test.go | 20 +++++++++ go/core/test/e2e/README.md | 3 ++ helm/kagent/values.yaml | 2 + proto/kagent/config/v1/config.proto | 2 + 17 files changed, 121 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a7f7d861bb..a35dd256a9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -154,6 +154,8 @@ jobs: port: http periodSeconds: 1 config: + sessions: + automaticRecovery: true push: allowHttp: true allowPrivateNetworks: true diff --git a/docs/architecture/process-configuration.md b/docs/architecture/process-configuration.md index b5e7d375de..7ecd8c1cae 100644 --- a/docs/architecture/process-configuration.md +++ b/docs/architecture/process-configuration.md @@ -99,6 +99,7 @@ push: signingPrivateKeyFile: secrets/push-private-key.pem sessions: idleTtl: 604800s + automaticRecovery: false runtimeDefaults: controlPlaneUrl: http://kagent-controller.kagent.svc:8083 gatewayUrl: http://kagent-controller.kagent.svc:8083 @@ -106,6 +107,10 @@ runtimeDefaults: level: info ``` +`sessions.automaticRecovery` enables recovery of crashed runtimes during sends. +It defaults to false. Explicit `RevertSession` calls remain available with either value. +The setting does not change migrations or stored data. + The controller connects with two logins. `database.owner` applies migrations at startup; `database.readWrite` serves every runtime query. Each assumes its `role` after connecting. Set `database.skipMigrations: true` when migrations are applied diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index 58b7630f7e..62635f3485 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1250,6 +1250,8 @@ func (x *SubstrateConfig) GetActorJwtIssuer() string { type SessionsConfig struct { state protoimpl.MessageState `protogen:"open.v1"` + // Recover crashed runtimes during sends. Disabled by default; explicit revert remains available. + AutomaticRecovery bool `protobuf:"varint,5,opt,name=automatic_recovery,json=automaticRecovery,proto3" json:"automatic_recovery,omitempty"` // Zero disables idle expiration. IdleTtl *durationpb.Duration `protobuf:"bytes,1,opt,name=idle_ttl,json=idleTtl,proto3" json:"idle_ttl,omitempty"` // Zero leaves session shares unbounded. @@ -1290,6 +1292,13 @@ func (*SessionsConfig) Descriptor() ([]byte, []int) { return file_kagent_config_v1_config_proto_rawDescGZIP(), []int{17} } +func (x *SessionsConfig) GetAutomaticRecovery() bool { + if x != nil { + return x.AutomaticRecovery + } + return false +} + func (x *SessionsConfig) GetIdleTtl() *durationpb.Duration { if x != nil { return x.IdleTtl @@ -2243,8 +2252,9 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\x10actor_jwt_issuer\x18\x05 \x01(\tB\x1a\xbaH\x17r\x152\x10^https://[^/?#]+\x88\x01\x01H\x02R\x0eactorJwtIssuer\x88\x01\x01B\x0f\n" + "\r_api_endpointB\r\n" + "\v_router_urlB\x13\n" + - "\x11_actor_jwt_issuer\"\xf6\x02\n" + - "\x0eSessionsConfig\x12F\n" + + "\x11_actor_jwt_issuer\"\xa5\x03\n" + + "\x0eSessionsConfig\x12-\n" + + "\x12automatic_recovery\x18\x05 \x01(\bR\x11automaticRecovery\x12F\n" + "\bidle_ttl\x18\x01 \x01(\v2\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n" + "\"\x06\b\x84\xfa\x85\xae\"2\x00R\aidleTtl\x12O\n" + "\rshare_max_ttl\x18\x02 \x01(\v2\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n" + diff --git a/go/core/internal/a2agateway/routing_test.go b/go/core/internal/a2agateway/routing_test.go index ef0135fa9e..69f1f70c4f 100644 --- a/go/core/internal/a2agateway/routing_test.go +++ b/go/core/internal/a2agateway/routing_test.go @@ -65,7 +65,7 @@ func newTestSessions(store *gatewayTestStore, authorizer auth.Authorizer) *sessi } func newTestGateway(store *gatewayTestStore, authorizer auth.Authorizer, dialer *gatewayTestDialer, url string) a2asrv.RequestHandler { - interactions := sessionsvc.NewInteractionService(store, gatewayTestAgents{store, authorizer}, newTestSessions(store, authorizer), false) + interactions := sessionsvc.NewInteractionService(store, gatewayTestAgents{store, authorizer}, newTestSessions(store, authorizer), false, true) return New(interactions, dialer, url) } @@ -142,3 +142,42 @@ func TestListWithoutContextRestrictsShareToOneConversation(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{gatewayTestID}, store.listedIDs) } + +func TestSendRecoveryDisabled(t *testing.T) { + for _, scenario := range []struct { + name string + pending, busy, refused bool + want error + }{ + {name: "an admitted recovery", pending: true, want: a2a.ErrUnsupportedOperation}, + {name: "an active turn", busy: true, want: a2a.ErrUnsupportedOperation}, + {name: "a runtime refusal", refused: true, want: sessionsvc.ErrSendNotAccepted}, + } { + t.Run("Given automatic recovery is disabled, when a send encounters "+scenario.name+", assert that it refuses without reverting", func(t *testing.T) { + session := gatewayTestSession() + if scenario.pending { + session.State, session.Operation = apiv1.RuntimeState_RUNTIME_STATE_FAILED, apiv1.RuntimeOperation_RUNTIME_OPERATION_REVERT + } + store := &gatewayTestStore{session: session, revoked: scenario.refused, taskErr: database.ErrNotFound} + if scenario.busy { + store.reserveErr = database.ErrConflict + } + runtime := &gatewayTestRuntime{} + if scenario.refused { + runtime.onSend = func() error { return a2a.ErrInternalError } + } + authorizer := &gatewayTestAuthorizer{} + interactions := sessionsvc.NewInteractionService(store, gatewayTestAgents{store, authorizer}, newTestSessions(store, authorizer), false, false) + gateway := New(interactions, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + _, err := gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + require.ErrorIs(t, err, scenario.want) + if !scenario.refused { + require.NotErrorIs(t, err, sessionsvc.ErrSendNotAccepted) + } + require.Zero(t, store.reverts) + if scenario.busy { + require.Equal(t, 1, store.reserveCalls) + } + }) + } +} diff --git a/go/core/internal/controller/scheduledrun/controller_test.go b/go/core/internal/controller/scheduledrun/controller_test.go index 77ef1b23e7..7a28bc178b 100644 --- a/go/core/internal/controller/scheduledrun/controller_test.go +++ b/go/core/internal/controller/scheduledrun/controller_test.go @@ -338,7 +338,7 @@ func TestControllerDispatchUsesBoundedInProcessAuthority(t *testing.T) { store := &scopedControllerStore{session: &apiv1.Session{Id: id, ContextId: id, Creator: "owner", Agent: &apiv1.AgentProvenance{Ref: &apiv1.ResourceReference{Namespace: "team", Name: "assistant"}}, State: apiv1.RuntimeState_RUNTIME_STATE_READY, Operation: apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE}} policy := &controllerSessionPolicy{} sessions := sessionsvc.NewService(store, policy, nil) - interactions := sessionsvc.NewInteractionService(store, nil, sessions, false) + interactions := sessionsvc.NewInteractionService(store, nil, sessions, false, false) ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) defer cancel() gateway := scopedControllerGateway{interactions: interactions, cancel: cancel, agent: types.NamespacedName{Namespace: "team", Name: "assistant"}} diff --git a/go/core/internal/grpcserver/scheduledrun_controller_test.go b/go/core/internal/grpcserver/scheduledrun_controller_test.go index 3db78afb94..7f6aecb60a 100644 --- a/go/core/internal/grpcserver/scheduledrun_controller_test.go +++ b/go/core/internal/grpcserver/scheduledrun_controller_test.go @@ -356,7 +356,7 @@ func TestScheduledRunControllerThroughGRPC(t *testing.T) { if tc.want == apiv1.ScheduledRunExecutionState_SCHEDULED_RUN_EXECUTION_STATE_SUCCEEDED && !tc.stream { controllerStore.loseTaskLink = true } - interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, scheduledControllerAuthorizer{}, healthyRuntimeWorkflow{}), false) + interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, scheduledControllerAuthorizer{}, healthyRuntimeWorkflow{}), false, false) controller := scheduledrun.NewController(controllerStore, workflow, a2agateway.New(interactions, dialer, "http://gateway.test"), time.Second) go func() { done <- controller.Start(ctx) }() t.Cleanup(func() { cancel(); require.NoError(t, <-done) }) diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index fb8cb4529d..bed8502d89 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -287,7 +287,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { var httpTransport a2aclient.Transport for i := range gateways { publicListener := bufconn.Listen(DefaultMaxMessageSize) - interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, &auth.NoopAuthorizer{}, healthyRuntimeWorkflow{}), true) + interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, &auth.NoopAuthorizer{}, healthyRuntimeWorkflow{}), true, false) gateway := a2agateway.New(interactions, taskStoreRuntimeDialer{runtimeListener}, "http://gateway.test") public, err := New(Config{ Listener: publicListener, SystemService: testSystemService(), @@ -443,7 +443,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { storedConfigs, err := store.ListTaskPushConfigs(t.Context(), id, parkedID, "", 10) require.NoError(t, err) require.Len(t, storedConfigs, 2, "stored configs: %+v", storedConfigs) - managedService := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, &auth.NoopAuthorizer{}, healthyRuntimeWorkflow{}), true) + managedService := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, &auth.NoopAuthorizer{}, healthyRuntimeWorkflow{}), true, false) serviceCtx := auth.AuthSessionTo(publicCtx, &authimpl.SimpleSession{P: auth.Principal{ID: "alice"}}) servicePage, err := managedService.ListTaskPushConfigs(serviceCtx, types.NamespacedName{Namespace: "team-a", Name: "assistant"}, &a2a.ListTaskPushConfigRequest{TaskID: a2a.TaskID(parkedID), PageSize: 1}) diff --git a/go/core/internal/service/session/expiration_test.go b/go/core/internal/service/session/expiration_test.go index 4acd1e36dc..49483a7c67 100644 --- a/go/core/internal/service/session/expiration_test.go +++ b/go/core/internal/service/session/expiration_test.go @@ -107,7 +107,7 @@ func TestSessionExpirationRetainsCheckpointHistory(t *testing.T) { store, source := lifecycleFixture(t) actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} _, checkpointID := lifecycleForkFixture(t, store, actors, source) - interactions := NewInteractionService(store, nil, NewService(store, serviceTestAuthorizer{}, nil), false) + interactions := NewInteractionService(store, nil, NewService(store, serviceTestAuthorizer{}, nil), false, false) agent := types.NamespacedName{Namespace: source.GetAgent().Ref.Namespace, Name: source.GetAgent().Ref.Name} // Real ownership filtering must hide an existing conversation in exactly // the same way as a missing or expired conversation, without reading history. diff --git a/go/core/internal/service/session/interactions.go b/go/core/internal/service/session/interactions.go index 2d161c86e4..d67e23a9c7 100644 --- a/go/core/internal/service/session/interactions.go +++ b/go/core/internal/service/session/interactions.go @@ -48,14 +48,15 @@ type agentService interface { // state. It never connects to actors: the gateway owns dispatch and observation. // These operations form the persistence boundary for a separate API service. type InteractionService struct { - store interactionStore - agents agentService - sessions *Service - allowPushHTTP bool + store interactionStore + agents agentService + sessions *Service + allowPushHTTP bool + automaticRecovery bool } -func NewInteractionService(store interactionStore, agents agentService, sessions *Service, allowPushHTTP bool) *InteractionService { - return &InteractionService{store: store, agents: agents, sessions: sessions, allowPushHTTP: allowPushHTTP} +func NewInteractionService(store interactionStore, agents agentService, sessions *Service, allowPushHTTP, automaticRecovery bool) *InteractionService { + return &InteractionService{store: store, agents: agents, sessions: sessions, allowPushHTTP: allowPushHTTP, automaticRecovery: automaticRecovery} } // PreparedSend either grants a new dispatch or returns an already accepted task. @@ -163,7 +164,7 @@ func (s *InteractionService) PrepareSend(ctx context.Context, agent types.Namesp req.Config = &config } id, err := s.reserveDispatch(ctx, session.Id, initialID) - if errors.Is(err, database.ErrConflict) { + if s.automaticRecovery && errors.Is(err, database.ErrConflict) { // A crash during a turn leaves that turn active, and active work refuses // every send, so no send would reach the crashed Actor to recover it. // Revert restores a crashed Actor and leaves a busy one alone, so one more @@ -174,9 +175,9 @@ func (s *InteractionService) PrepareSend(ctx context.Context, agent types.Namesp } else if !errors.Is(recoverErr, a2atype.ErrUnauthorized) { return nil, recoverErr } - if errors.Is(err, database.ErrConflict) { - err = interactionStoreError(ctx, err) - } + } + if errors.Is(err, database.ErrConflict) { + err = interactionStoreError(ctx, err) } if errors.Is(err, database.ErrMessageAccepted) { var historyLength *int @@ -279,6 +280,9 @@ func (s *InteractionService) RevokeSend(ctx context.Context, agent types.Namespa // 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 { + return ErrSendNotAccepted + } session, err := s.messageSession(ctx, agent, message) if err != nil { return err @@ -493,7 +497,7 @@ func (s *InteractionService) resolveSend(ctx context.Context, agent types.Namesp if database.RuntimeLost(session) { return nil, ErrRuntimeUnrecoverable } - if database.CrashRecoveryAdmitted(session) { + if s.automaticRecovery && database.CrashRecoveryAdmitted(session) { if err := s.recoverRuntime(ctx, session.Id); err != nil { return nil, err } diff --git a/go/core/internal/service/session/interactions_test.go b/go/core/internal/service/session/interactions_test.go index 21421bc391..2c954f687d 100644 --- a/go/core/internal/service/session/interactions_test.go +++ b/go/core/internal/service/session/interactions_test.go @@ -59,7 +59,7 @@ func TestPrepareSendStampsInboundTimeline(t *testing.T) { if test.accepted { tasks.dispatchErr = database.ErrMessageAccepted } - service := NewInteractionService(tasks, nil, NewService(&serviceTestStore{getResult: stored}, serviceTestAuthorizer{}, nil), false) + service := NewInteractionService(tasks, nil, NewService(&serviceTestStore{getResult: stored}, serviceTestAuthorizer{}, nil), false, false) message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("Approved")) message.ID, message.ContextID, message.TaskID = original.ID, id, test.taskID apia2a.SetTimelinePosition(message, time.Unix(1, 0)) @@ -99,7 +99,7 @@ func TestPrepareSendReservesTaskOpeningMessages(t *testing.T) { Operation: apiv1.RuntimeOperation_RUNTIME_OPERATION_NONE, Agent: &apiv1.AgentProvenance{Ref: &apiv1.ResourceReference{Namespace: agent.Namespace, Name: agent.Name}}} tasks := &interactionTestStore{task: &a2atype.Task{ID: "task", ContextID: id, Status: a2atype.TaskStatus{State: a2atype.TaskStateInputRequired}}} - service := NewInteractionService(tasks, nil, NewService(&serviceTestStore{getResult: stored}, serviceTestAuthorizer{}, nil), false) + service := NewInteractionService(tasks, nil, NewService(&serviceTestStore{getResult: stored}, serviceTestAuthorizer{}, nil), false, false) message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")) message.ID, message.ContextID, message.TaskID = "opening", id, test.taskID _, err := service.PrepareSend(serviceTestContext("alice"), agent, &a2atype.SendMessageRequest{Message: message}) @@ -244,7 +244,7 @@ func TestInteractionsEnforcePermissionsWithoutGateway(t *testing.T) { sessionStore.getResult = nil } tasks := &interactionTestStore{task: &a2atype.Task{ID: "task", ContextID: id, Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}}} - service := NewInteractionService(tasks, nil, NewService(sessionStore, authorizer, nil), false) + service := NewInteractionService(tasks, nil, NewService(sessionStore, authorizer, nil), false, false) want := test.want _, shared := auth.ShareContextFrom(test.ctx) if shared && operation.verb != auth.VerbGet { @@ -273,7 +273,7 @@ func TestInteractionsEnforcePermissionsWithoutGateway(t *testing.T) { } func TestInteractionsRequireAgentForUnfilteredTaskList(t *testing.T) { - service := NewInteractionService(nil, nil, nil, false) + service := NewInteractionService(nil, nil, nil, false, false) for _, agent := range []types.NamespacedName{ {}, {Namespace: "team-a"}, @@ -298,7 +298,7 @@ func TestExistingConversationDoesNotRequireCurrentAgentGrant(t *testing.T) { ctx := serviceTestContext("alice") message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("continue")) message.ContextID = id - _, err := NewInteractionService(nil, nil, sessions, false).messageSession(ctx, agent, message) + _, err := NewInteractionService(nil, nil, sessions, false, false).messageSession(ctx, agent, message) if denySession { if !errors.Is(err, a2atype.ErrUnauthorized) { t.Fatalf("error = %v, want Session denial", err) @@ -334,7 +334,7 @@ func TestScheduledMessageIDPrefixIsReserved(t *testing.T) { sessions := NewService(&serviceTestStore{getResult: stored}, &recordingAuthorizer{}, serviceTestWorkflow{}) message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("continue")) message.ID, message.ContextID = test.id, id - _, err := NewInteractionService(nil, nil, sessions, false).messageSession(test.ctx, agent, message) + _, err := NewInteractionService(nil, nil, sessions, false, false).messageSession(test.ctx, agent, message) if test.wantOK { require.NoError(t, err) } else { @@ -350,7 +350,7 @@ func TestScheduledMessageIDPrefixCannotStartAConversation(t *testing.T) { message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")) message.ID = scheduling.MessageIDPrefix + uuid.NewString() agent := types.NamespacedName{Namespace: "team-a", Name: "assistant"} - _, err := NewInteractionService(nil, nil, sessions, false).PrepareSend(serviceTestContext("alice"), agent, &a2atype.SendMessageRequest{Message: message}) + _, err := NewInteractionService(nil, nil, sessions, false, false).PrepareSend(serviceTestContext("alice"), agent, &a2atype.SendMessageRequest{Message: message}) require.ErrorIs(t, err, a2atype.ErrInvalidParams) require.Nil(t, store.createInput) } diff --git a/go/core/internal/service/session/recovery_test.go b/go/core/internal/service/session/recovery_test.go index 919a503745..168e69fe47 100644 --- a/go/core/internal/service/session/recovery_test.go +++ b/go/core/internal/service/session/recovery_test.go @@ -607,7 +607,7 @@ func TestSendRecoveryAnswersTheSend(t *testing.T) { session := &apiv1.Session{Id: uuid.NewString(), State: apiv1.RuntimeState_RUNTIME_STATE_READY} authorizer := &verbAuthorizer{denied: auth.VerbUpdate, err: test.authErr} sessions := NewService(&serviceTestStore{getResult: session}, authorizer, serviceTestWorkflow{err: test.err}) - interactions := NewInteractionService(nil, nil, sessions, false) + interactions := NewInteractionService(nil, nil, sessions, false, true) err := interactions.recoverRuntime(serviceTestContext("alice"), session.Id) if test.want == nil { require.NoError(t, err) @@ -647,7 +647,7 @@ func TestRecoverRejectedSendReturnsSessionErrors(t *testing.T) { {name: "permission denied", store: &serviceTestStore{}, authorizer: serviceTestAuthorizer{err: serviceerrors.NewPermissionDenied("denied", nil)}, want: a2a.ErrUnauthorized}, } { t.Run(test.name, func(t *testing.T) { - interactions := NewInteractionService(nil, nil, NewService(test.store, test.authorizer, serviceTestWorkflow{}), false) + interactions := NewInteractionService(nil, nil, NewService(test.store, test.authorizer, serviceTestWorkflow{}), false, true) message := &a2a.Message{ID: "input", ContextID: uuid.NewString()} err := interactions.RecoverRejectedSend(serviceTestContext("alice"), types.NamespacedName{Namespace: "team-a", Name: "assistant"}, message) require.ErrorIs(t, err, test.want) diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 2b3458dbd5..6671d9c182 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -376,7 +376,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er return err } agents := kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Agent{}, &kagentv1alpha3.AgentList{}, "Agent") - interactions := sessionsvc.NewInteractionService(store, agents, sessions, cfg.GetPush().GetAllowHttp()) + interactions := sessionsvc.NewInteractionService(store, agents, sessions, cfg.GetPush().GetAllowHttp(), cfg.GetSessions().GetAutomaticRecovery()) pushSender := sessionsvc.NewHTTPPushSender(cfg.GetPush().GetSendTimeout().AsDuration(), cfg.GetPush().GetAllowHttp(), cfg.GetPush().GetAllowPrivateNetworks()) pushIssuer := cfg.GetPush().GetIssuer() diff --git a/go/core/pkg/config/helm_test.go b/go/core/pkg/config/helm_test.go index 8c49153290..ccf972ee93 100644 --- a/go/core/pkg/config/helm_test.go +++ b/go/core/pkg/config/helm_test.go @@ -71,6 +71,11 @@ func TestHelmControllerConfig(t *testing.T) { require.Equal(t, "team", base.Kubernetes.GetNamespace()) require.Equal(t, "http://demo-controller.team.svc.cluster.local:8083", base.RuntimeDefaults.GetControlPlaneUrl()) require.NotEmpty(t, checksum) + require.False(t, base.GetSessions().GetAutomaticRecovery()) + recovery, recoveryChecksum, err := render("config:\n sessions: {automaticRecovery: true}\n") + require.NoError(t, err) + require.True(t, recovery.GetSessions().GetAutomaticRecovery()) + require.NotEqual(t, checksum, recoveryChecksum) require.Nil(t, base.GetServer().GetAuth().GetServiceAccount()) machine, _, err := render("config:\n server:\n auth:\n serviceAccount: {authority: cluster-a}\n") require.NoError(t, err) diff --git a/go/core/pkg/config/load_test.go b/go/core/pkg/config/load_test.go index 89d2dc2566..c0c6665068 100644 --- a/go/core/pkg/config/load_test.go +++ b/go/core/pkg/config/load_test.go @@ -211,3 +211,23 @@ func TestYAMLConfigReadsOnlyFirstDocument(t *testing.T) { require.NoError(t, config.Decode([]byte("logging: {level: debug}\n---\n["), cfg)) require.Equal(t, "debug", cfg.GetLogging().GetLevel()) } + +func TestSessionAutomaticRecovery(t *testing.T) { + for _, scenario := range []struct { + name, input string + want bool + }{ + {"omitted", "{}", false}, + {"disabled", "sessions: {automaticRecovery: false}", false}, + {"enabled", "sessions: {automaticRecovery: true}", true}, + } { + t.Run("Given controller config with automatic recovery "+scenario.name+", when it loads, assert that the setting is preserved", func(t *testing.T) { + cfg := &configv1.ControllerConfig{} + require.NoError(t, config.Decode([]byte(scenario.input), cfg)) + config.DefaultController(cfg) + setLogins(cfg) + require.NoError(t, config.Validate(cfg)) + require.Equal(t, scenario.want, cfg.GetSessions().GetAutomaticRecovery()) + }) + } +} diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index 34e3c4d8d2..24f811985f 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -35,6 +35,9 @@ Once a cluster API URL is set, missing `KAGENT_E2E_RUNTIME_IMAGE` fails the test before provisioning. The same digest-pinned Go ADK image supplies both the agent runtime and sandbox tools. +`TestSessionSendRecoversCrashedRuntime` requires `config.sessions.automaticRecovery: true` +in the test install. CI enables it to exercise recovery through ordinary sends. + `TestSessionIdleExpiration` runs its harness cases in parallel so they share the idle period and expiration sweep. Its parent stays sequential to isolate the controller rollouts from the rest of the suite. It temporarily sets diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 69c5b77417..499b936b70 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -186,6 +186,8 @@ config: # Recovery scans for missed wake-ups and unbound registration cleanup. recoveryInterval: 60s sessions: + # Recover crashed runtimes when a client sends another message. + automaticRecovery: false idleTtl: 604800s shareMaxTtl: 0s sandboxes: diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index fd97827559..ac529c064f 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -231,6 +231,8 @@ message SubstrateConfig { } message SessionsConfig { + // Recover crashed runtimes during sends. Disabled by default; explicit revert remains available. + bool automatic_recovery = 5; // Zero disables idle expiration. google.protobuf.Duration idle_ttl = 1 [(buf.validate.field).duration = { gte: {}