@@ -1999,7 +1999,8 @@ func queuedMessageAttachmentsToV1(attachments []messagequeue.MessageAttachment)
|
||||
// entity references are re-added via WithEntityReferences, and a carried
|
||||
// completion handoff (messagequeue.MetadataStepHandoff) is already folded
|
||||
// into the recorded content by the caller, so neither belongs in the row's
|
||||
// own stored metadata.
|
||||
// own stored metadata. Admission provenance remains so clients can reconcile
|
||||
// an accepted message after its queue row has been dispatched.
|
||||
func metadataWithoutQueueOnlyKeys(metadata map[string]interface{}) map[string]interface{} {
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -422,6 +422,31 @@ func TestExecuteQueuedMessage_StoresAttachmentsInUserMessageMetadata(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordQueuedUserMessagePreservesOrdinaryAdmissionProvenance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo())
|
||||
messages := &mockMessageCreator{}
|
||||
svc.messageCreator = messages
|
||||
queued := &messagequeue.QueuedMessage{
|
||||
ID: "ordinary-queue-row", SessionID: "s1", TaskID: "t1", Content: "queued prompt",
|
||||
QueuedBy: messagequeue.QueuedByUser,
|
||||
Metadata: map[string]interface{}{
|
||||
messagequeue.MetadataQueueAdmissionIDs: []string{"client-first", "client-second"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.recordQueuedUserMessage(ctx, queued, nil); err != nil {
|
||||
t.Fatalf("record ordinary transcript: %v", err)
|
||||
}
|
||||
if len(messages.userMessages) != 1 {
|
||||
t.Fatalf("recorded messages = %d, want 1", len(messages.userMessages))
|
||||
}
|
||||
ids, ok := messages.userMessages[0].metadata[messagequeue.MetadataQueueAdmissionIDs].([]string)
|
||||
if !ok || !reflect.DeepEqual(ids, []string{"client-first", "client-second"}) {
|
||||
t.Fatalf("ordinary admission provenance = %v, valid=%v", ids, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteQueuedMessageTransientRetryDoesNotDuplicateRecordedUserMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
|
||||
@@ -121,6 +121,34 @@ type QueueIdentityAttachmentAdmissionService interface {
|
||||
) (*messagequeue.QueuedMessage, error)
|
||||
}
|
||||
|
||||
// QueueIdentityClientAdmissionService admits a browser queue message with a
|
||||
// caller-owned identity that can be replayed after an uncertain response.
|
||||
type QueueIdentityClientAdmissionService interface {
|
||||
LookupQueueAdmissionWithClientQueueID(
|
||||
context.Context,
|
||||
messagequeue.QueueSessionIdentity,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
bool,
|
||||
[]messagequeue.MessageAttachment,
|
||||
map[string]interface{},
|
||||
) (*messagequeue.QueuedMessage, bool, error)
|
||||
QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
context.Context,
|
||||
messagequeue.QueueSessionIdentity,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
bool,
|
||||
[]messagequeue.MessageAttachment,
|
||||
map[string]interface{},
|
||||
*messagequeue.QueueAttachmentClaim,
|
||||
) (*messagequeue.QueuedMessage, bool, error)
|
||||
}
|
||||
|
||||
type QueueIdentityMutationService interface {
|
||||
AppendContentForSession(context.Context, messagequeue.QueueSessionIdentity, string, string, string, bool, []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, bool, error)
|
||||
UpdateMessageWithMetadataForSession(context.Context, messagequeue.QueueSessionIdentity, string, string, []messagequeue.MessageAttachment, map[string]interface{}, string) error
|
||||
@@ -555,6 +583,9 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id, session_id, and session_incarnation_id are required", nil)
|
||||
}
|
||||
if denied := h.authorizeQueueIdentity(ctx, msg, req.TaskID, req.SessionID, req.SessionIncarnationID); denied != nil {
|
||||
if req.ClientQueueID != "" {
|
||||
return ws.NewError(msg.ID, msg.Action, "queue_session_unavailable", "Session is no longer available", nil)
|
||||
}
|
||||
return denied, nil
|
||||
}
|
||||
if req.Content == "" && len(req.Attachments) == 0 && len(req.PlanCommentRefs) == 0 {
|
||||
@@ -574,11 +605,21 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
if messagequeue.IsReservedQueuedBy(req.UserID) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, reservedIdentityError(req.UserID), nil)
|
||||
}
|
||||
references, err := h.validateSubmittedReferences(ctx, req.SessionID, req.TaskID, req.EntityReferences)
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil)
|
||||
var err error
|
||||
identifiedOrdinary := req.ClientQueueID != "" && len(req.PlanCommentRefs) == 0
|
||||
if identifiedOrdinary {
|
||||
references, normalizeErr := entityrefs.NormalizeForSubmission(req.EntityReferences)
|
||||
if normalizeErr != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil)
|
||||
}
|
||||
req.EntityReferences = references
|
||||
} else {
|
||||
references, validationErr := h.validateSubmittedReferences(ctx, req.SessionID, req.TaskID, req.EntityReferences)
|
||||
if validationErr != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil)
|
||||
}
|
||||
req.EntityReferences = references
|
||||
}
|
||||
req.EntityReferences = references
|
||||
|
||||
// Default empty user_id to QueuedByUser so the entry has a non-empty owner;
|
||||
// the UpdateMessage handler relies on this so its filter against agent
|
||||
@@ -591,16 +632,28 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
WithContextFiles(req.ContextFiles).
|
||||
WithEntityReferences(req.EntityReferences).
|
||||
ToMap()
|
||||
if identifiedOrdinary {
|
||||
if metadata == nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
metadata[messagequeue.MetadataQueueAdmissionIDs] = []string{req.ClientQueueID}
|
||||
}
|
||||
var snapshot *models.TaskPlanCommentSnapshot
|
||||
var replay bool
|
||||
var queued *messagequeue.QueuedMessage
|
||||
if len(req.PlanCommentRefs) > 0 {
|
||||
switch {
|
||||
case identifiedOrdinary:
|
||||
queued, replay, err = h.admitIdentifiedOrdinaryQueuedMessage(ctx, &req, queuedBy, metadata)
|
||||
if errors.Is(err, errQueueInvalidReferences) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil)
|
||||
}
|
||||
case len(req.PlanCommentRefs) > 0:
|
||||
result, admissionErr := h.admitPlanCommentQueuedMessage(ctx, &req, queuedBy, metadata)
|
||||
err = admissionErr
|
||||
if result != nil {
|
||||
queued, snapshot, replay = result.Message, result.Snapshot, result.Replay
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
queued, err = h.admitQueuedMessage(ctx, &req, queuedBy, metadata)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -609,6 +662,18 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
TaskID: req.TaskID, SessionID: req.SessionID, SessionIncarnationID: req.SessionIncarnationID,
|
||||
})
|
||||
}
|
||||
if identifiedOrdinary {
|
||||
if errors.Is(err, messagequeue.ErrQueueIDConflict) {
|
||||
return ws.NewError(msg.ID, msg.Action, "queue_id_conflict", "client_queue_id is already used", nil)
|
||||
}
|
||||
if errors.Is(err, messagequeue.ErrSessionIdentityMismatch) ||
|
||||
errors.Is(err, messagequeue.ErrTaskInactive) {
|
||||
return ws.NewError(msg.ID, msg.Action, "queue_session_unavailable", "Session is no longer available", nil)
|
||||
}
|
||||
if errors.Is(err, messagequeue.ErrQueueAdmissionUnavailable) {
|
||||
return ws.NewError(msg.ID, msg.Action, "queue_admission_unavailable", "Queue admission is unavailable", nil)
|
||||
}
|
||||
}
|
||||
if errors.Is(err, messagequeue.ErrTaskInactive) {
|
||||
// The task was archived or deleted between the caller's
|
||||
// authorization and the queue admission; do not queue a message
|
||||
@@ -644,13 +709,48 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
}
|
||||
|
||||
var (
|
||||
errQueueInvalidReferences = errors.New("invalid queued entity references")
|
||||
errQueuedAttachmentUnavailable = errors.New("queued attachment unavailable")
|
||||
errQueuedAttachmentRollback = errors.New("queued attachment rollback failed")
|
||||
errAttachmentCleanupLeaseActive = errors.New("queue attachment cleanup blocked by edit lease")
|
||||
errAttachmentCleanupEntryChanged = errors.New("queue attachment cleanup entry changed")
|
||||
)
|
||||
|
||||
func (h *QueueHandlers) admitIdentifiedOrdinaryQueuedMessage(
|
||||
ctx context.Context,
|
||||
req *wsQueueMessageRequest,
|
||||
queuedBy string,
|
||||
metadata map[string]interface{},
|
||||
) (*messagequeue.QueuedMessage, bool, error) {
|
||||
queued, replay, err := h.lookupIdentifiedQueuedMessage(ctx, req, queuedBy, metadata)
|
||||
if err != nil || replay {
|
||||
return queued, replay, err
|
||||
}
|
||||
references, err := h.validateSubmittedReferences(ctx, req.SessionID, req.TaskID, req.EntityReferences)
|
||||
if err != nil {
|
||||
replayed, replayedFound, lookupErr := h.lookupIdentifiedQueuedMessage(ctx, req, queuedBy, metadata)
|
||||
if lookupErr == nil && replayedFound {
|
||||
return replayed, true, nil
|
||||
}
|
||||
return nil, false, errQueueInvalidReferences
|
||||
}
|
||||
req.EntityReferences = references
|
||||
metadata = orchestrator.NewUserMessageMeta().
|
||||
WithContextFiles(req.ContextFiles).
|
||||
WithEntityReferences(req.EntityReferences).
|
||||
ToMap()
|
||||
if metadata == nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
metadata[messagequeue.MetadataQueueAdmissionIDs] = []string{req.ClientQueueID}
|
||||
queued, err = h.admitIdentifiedQueuedMessage(ctx, req, queuedBy, metadata)
|
||||
return queued, false, err
|
||||
}
|
||||
|
||||
func (h *QueueHandlers) admitQueuedMessage(ctx context.Context, req *wsQueueMessageRequest, queuedBy string, metadata map[string]interface{}) (*messagequeue.QueuedMessage, error) {
|
||||
if req.ClientQueueID != "" {
|
||||
return h.admitIdentifiedQueuedMessage(ctx, req, queuedBy, metadata)
|
||||
}
|
||||
if !h.requiresQueueIdentity() {
|
||||
if h.attachmentClaimer == nil || len(req.Attachments) == 0 {
|
||||
return h.queueService.QueueMessageWithMetadata(
|
||||
@@ -749,6 +849,63 @@ func (h *QueueHandlers) admitQueuedMessage(ctx context.Context, req *wsQueueMess
|
||||
)
|
||||
}
|
||||
|
||||
func (h *QueueHandlers) admitIdentifiedQueuedMessage(
|
||||
ctx context.Context,
|
||||
req *wsQueueMessageRequest,
|
||||
queuedBy string,
|
||||
metadata map[string]interface{},
|
||||
) (*messagequeue.QueuedMessage, error) {
|
||||
if !h.requiresQueueIdentity() {
|
||||
return nil, messagequeue.ErrQueueAdmissionUnavailable
|
||||
}
|
||||
admissions, ok := h.queueService.(QueueIdentityClientAdmissionService)
|
||||
if !ok {
|
||||
return nil, messagequeue.ErrQueueAdmissionUnavailable
|
||||
}
|
||||
identity := messagequeue.QueueSessionIdentity{
|
||||
TaskID: req.TaskID, SessionID: req.SessionID, SessionIncarnationID: req.SessionIncarnationID,
|
||||
}
|
||||
var claim *messagequeue.QueueAttachmentClaim
|
||||
if len(req.Attachments) > 0 && h.attachmentClaimer != nil {
|
||||
preparer, canPrepare := h.attachmentClaimer.(QueueAttachmentClaimPreparer)
|
||||
if !canPrepare {
|
||||
return nil, messagequeue.ErrQueueAdmissionUnavailable
|
||||
}
|
||||
prepared, err := preparer.PrepareQueueAttachmentClaim(ctx, req.TaskID, queueAttachmentsToV1(req.Attachments))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errQueuedAttachmentUnavailable, err)
|
||||
}
|
||||
claim = &prepared
|
||||
}
|
||||
queued, _, err := admissions.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, req.ClientQueueID, req.Content, req.Model, queuedBy,
|
||||
req.PlanMode, req.Attachments, metadata, claim,
|
||||
)
|
||||
return queued, err
|
||||
}
|
||||
|
||||
func (h *QueueHandlers) lookupIdentifiedQueuedMessage(
|
||||
ctx context.Context,
|
||||
req *wsQueueMessageRequest,
|
||||
queuedBy string,
|
||||
metadata map[string]interface{},
|
||||
) (*messagequeue.QueuedMessage, bool, error) {
|
||||
if !h.requiresQueueIdentity() {
|
||||
return nil, false, messagequeue.ErrQueueAdmissionUnavailable
|
||||
}
|
||||
admissions, ok := h.queueService.(QueueIdentityClientAdmissionService)
|
||||
if !ok {
|
||||
return nil, false, messagequeue.ErrQueueAdmissionUnavailable
|
||||
}
|
||||
identity := messagequeue.QueueSessionIdentity{
|
||||
TaskID: req.TaskID, SessionID: req.SessionID, SessionIncarnationID: req.SessionIncarnationID,
|
||||
}
|
||||
return admissions.LookupQueueAdmissionWithClientQueueID(
|
||||
ctx, identity, req.ClientQueueID, req.Content, req.Model, queuedBy,
|
||||
req.PlanMode, req.Attachments, metadata,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *QueueHandlers) admitPlanCommentQueuedMessage(
|
||||
ctx context.Context,
|
||||
req *wsQueueMessageRequest,
|
||||
@@ -782,15 +939,15 @@ func (h *QueueHandlers) admitPlanCommentQueuedMessage(
|
||||
}
|
||||
|
||||
func validatePlanCommentQueueRequest(req wsQueueMessageRequest) string {
|
||||
if len(req.ClientQueueID) > messagequeue.MaxQueueAdmissionIDLength {
|
||||
return "client_queue_id is too long"
|
||||
}
|
||||
if len(req.PlanCommentRefs) == 0 {
|
||||
return ""
|
||||
}
|
||||
if req.ClientQueueID == "" {
|
||||
return "client_queue_id is required with plan comments"
|
||||
}
|
||||
if len(req.ClientQueueID) > 128 {
|
||||
return "client_queue_id is too long"
|
||||
}
|
||||
if plancomments.ContainsReservedPlaceholder(req.Content) {
|
||||
return "content contains a reserved plan comment marker"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/entityrefs"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -15,6 +19,37 @@ type recordingQueueAdmissionReadinessChecker struct {
|
||||
identity messagequeue.QueueSessionIdentity
|
||||
}
|
||||
|
||||
type denyingQueueIdentityAccess struct {
|
||||
allowQueueIdentityAccess
|
||||
}
|
||||
|
||||
type taskInactiveQueueService struct {
|
||||
*messagequeue.Service
|
||||
}
|
||||
|
||||
func (taskInactiveQueueService) QueueMessageWithMetadata(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
bool,
|
||||
[]messagequeue.MessageAttachment,
|
||||
map[string]interface{},
|
||||
) (*messagequeue.QueuedMessage, error) {
|
||||
return nil, messagequeue.ErrTaskInactive
|
||||
}
|
||||
|
||||
func (denyingQueueIdentityAccess) AuthorizeTaskSessionIncarnationAccess(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
) error {
|
||||
return errors.New("session is unavailable")
|
||||
}
|
||||
|
||||
func (c *recordingQueueAdmissionReadinessChecker) DrainQueuedMessage(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -58,3 +93,145 @@ func TestWsQueueMessageChecksReadinessAfterSuccessfulAdmission(t *testing.T) {
|
||||
SessionIncarnationID: "memory:session-1",
|
||||
}, checker.identity)
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.1
|
||||
func TestWsQueueMessagePreservesOrdinaryClientAdmissionIdentity(t *testing.T) {
|
||||
log := logger.Default()
|
||||
queue := messagequeue.NewServiceMemory(log)
|
||||
handlers := NewQueueHandlers(
|
||||
queue,
|
||||
&mockEventBus{},
|
||||
log,
|
||||
nil,
|
||||
allowQueueIdentityAccess{},
|
||||
nil,
|
||||
)
|
||||
|
||||
response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"session_incarnation_id": "memory:session-1",
|
||||
"client_queue_id": "ordinary-admission-1",
|
||||
"content": "send once",
|
||||
}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, response.Type)
|
||||
var queued messagequeue.QueuedMessage
|
||||
require.NoError(t, json.Unmarshal(response.Payload, &queued))
|
||||
require.Equal(t, "ordinary-admission-1", queued.ID)
|
||||
entries, _, err := queue.SnapshotSession(context.Background(), "session-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, []string{"ordinary-admission-1"}, entries[0].Metadata[messagequeue.MetadataQueueAdmissionIDs])
|
||||
}
|
||||
|
||||
func TestWsQueueMessagePreservesLegacyTaskInactiveError(t *testing.T) {
|
||||
log := logger.Default()
|
||||
service := taskInactiveQueueService{Service: messagequeue.NewServiceMemory(log)}
|
||||
handlers := NewQueueHandlers(service, &mockEventBus{}, log, nil, allowQueueAccess{}, nil)
|
||||
|
||||
response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"content": "legacy submission",
|
||||
}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeError, response.Type)
|
||||
queueError := parseError(t, response)
|
||||
require.Equal(t, ws.ErrorCodeValidation, queueError.Code)
|
||||
require.Equal(t, "Task is no longer active", queueError.Message)
|
||||
}
|
||||
|
||||
func TestWsQueueMessageMapsDeniedIdentifiedSessionToAdmissionUnavailable(t *testing.T) {
|
||||
log := logger.Default()
|
||||
handlers := NewQueueHandlers(
|
||||
messagequeue.NewServiceMemory(log),
|
||||
&mockEventBus{},
|
||||
log,
|
||||
nil,
|
||||
denyingQueueIdentityAccess{},
|
||||
nil,
|
||||
)
|
||||
|
||||
response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"session_incarnation_id": "memory:session-1",
|
||||
"client_queue_id": "ordinary-admission-denied",
|
||||
"content": "retain this draft",
|
||||
}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeError, response.Type)
|
||||
require.Equal(t, "queue_session_unavailable", parseError(t, response).Code)
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.2
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.3
|
||||
func TestWsQueueMessageReplaysOrdinaryAdmissionWithoutDuplicatingContent(t *testing.T) {
|
||||
log := logger.Default()
|
||||
queue := messagequeue.NewServiceMemory(log)
|
||||
handlers := NewQueueHandlers(queue, &mockEventBus{}, log, nil, allowQueueIdentityAccess{}, nil)
|
||||
payload := map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"session_incarnation_id": "memory:session-1",
|
||||
"client_queue_id": "ordinary-admission-2",
|
||||
"content": "send once",
|
||||
}
|
||||
|
||||
first, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, payload))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, first.Type)
|
||||
second, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, payload))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, second.Type)
|
||||
entries, _, err := queue.SnapshotSession(context.Background(), "session-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
|
||||
payload["content"] = "changed"
|
||||
conflict, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, payload))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeError, conflict.Type)
|
||||
require.Equal(t, "queue_id_conflict", parseError(t, conflict).Code)
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.2
|
||||
func TestWsQueueMessageReplaysBeforeMutableReferenceValidation(t *testing.T) {
|
||||
reference := v1.EntityReference{
|
||||
Version: v1.EntityReferenceVersion,
|
||||
Ref: entityrefs.CanonicalRef("kandev", "task", "workspace-1", "task-2"),
|
||||
Provider: "kandev", Kind: "task", ID: "task-2",
|
||||
Title: "Referenced task", URL: "/t/task-2", Scope: "workspace-1",
|
||||
}
|
||||
validator := &fakeReferenceSubmissionValidator{result: []v1.EntityReference{reference}}
|
||||
queue := messagequeue.NewServiceMemory(logger.Default())
|
||||
handlers := NewQueueHandlers(queue, &mockEventBus{}, logger.Default(), nil, allowQueueIdentityAccess{}, nil, validator)
|
||||
handlers.Start(context.Background())
|
||||
t.Cleanup(handlers.Stop)
|
||||
payload := map[string]interface{}{
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"session_incarnation_id": "memory:session-1",
|
||||
"client_queue_id": "reference-replay-1",
|
||||
"content": "send once",
|
||||
"entity_references": []v1.EntityReference{reference},
|
||||
}
|
||||
|
||||
first, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, payload))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, first.Type)
|
||||
require.Equal(t, 1, validator.calls)
|
||||
|
||||
validator.err = entityrefs.ErrUnauthorizedReference
|
||||
second, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, payload))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, second.Type)
|
||||
require.Equal(t, 1, validator.calls, "exact replay must not reauthorize mutable references")
|
||||
entries, _, err := queue.SnapshotSession(context.Background(), "session-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package messagequeue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/plancomments"
|
||||
)
|
||||
|
||||
// MaxQueueAdmissionIDLength bounds the caller-owned identity retained by a
|
||||
// queue admission receipt.
|
||||
const MaxQueueAdmissionIDLength = 128
|
||||
|
||||
// ErrQueueAdmissionUnavailable means the repository cannot provide durable
|
||||
// replay protection for an identified queue admission.
|
||||
var ErrQueueAdmissionUnavailable = errors.New("durable queue admission is unavailable")
|
||||
|
||||
type queueAdmissionRepository interface {
|
||||
LookupQueueAdmission(
|
||||
context.Context,
|
||||
QueueSessionIdentity,
|
||||
string,
|
||||
*QueuedMessage,
|
||||
) (*QueuedMessage, bool, error)
|
||||
AdmitQueueMessage(
|
||||
context.Context,
|
||||
QueueSessionIdentity,
|
||||
string,
|
||||
*QueuedMessage,
|
||||
*QueueAttachmentClaim,
|
||||
int,
|
||||
*AutoMergePolicy,
|
||||
) (*QueuedMessage, bool, error)
|
||||
}
|
||||
|
||||
type queueAdmissionKey struct {
|
||||
TaskID string
|
||||
SessionID string
|
||||
SessionIncarnationID string
|
||||
ClientQueueID string
|
||||
}
|
||||
|
||||
type queueAdmissionReceipt struct {
|
||||
Fingerprint string
|
||||
Message *QueuedMessage
|
||||
}
|
||||
|
||||
type queueAdmissionFingerprintInput struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionIncarnationID string `json:"session_incarnation_id"`
|
||||
Content string `json:"content"`
|
||||
Model string `json:"model"`
|
||||
PlanMode bool `json:"plan_mode"`
|
||||
Attachments []MessageAttachment `json:"attachments"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
QueuedBy string `json:"queued_by"`
|
||||
}
|
||||
|
||||
func queueAdmissionFingerprint(identity QueueSessionIdentity, message *QueuedMessage) (string, error) {
|
||||
if message == nil {
|
||||
return "", errors.New("queue admission message is nil")
|
||||
}
|
||||
attachments := append([]MessageAttachment(nil), message.Attachments...)
|
||||
if attachments == nil {
|
||||
attachments = []MessageAttachment{}
|
||||
}
|
||||
metadata := copyMessageMetadata(message.Metadata, 0)
|
||||
if metadata == nil {
|
||||
metadata = map[string]interface{}{}
|
||||
}
|
||||
delete(metadata, plancomments.MetadataClientQueueID)
|
||||
delete(metadata, plancomments.MetadataRequestFingerprint)
|
||||
delete(metadata, plancomments.MetadataClientMessageFingerprint)
|
||||
delete(metadata, MetadataQueueAdmissionIDs)
|
||||
input := queueAdmissionFingerprintInput{
|
||||
TaskID: identity.TaskID, SessionID: identity.SessionID,
|
||||
SessionIncarnationID: identity.SessionIncarnationID,
|
||||
Content: message.Content, Model: message.Model, PlanMode: message.PlanMode,
|
||||
Attachments: attachments, Metadata: metadata, QueuedBy: message.QueuedBy,
|
||||
}
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(digest[:]), nil
|
||||
}
|
||||
|
||||
func queueAdmissionResponseSnapshot(message *QueuedMessage) *QueuedMessage {
|
||||
snapshot := cloneQueuedMessage(message)
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
for index := range snapshot.Attachments {
|
||||
snapshot.Attachments[index].Data = ""
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package messagequeue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrInvalidQueueAdmissionIDs reports malformed internal provenance metadata.
|
||||
// A merge must leave both queue entries untouched when its provenance cannot
|
||||
// be normalized safely.
|
||||
var ErrInvalidQueueAdmissionIDs = errors.New("invalid queue admission IDs")
|
||||
|
||||
func unionQueueAdmissionIDs(target, source map[string]interface{}) ([]string, bool) {
|
||||
seen := make(map[string]struct{})
|
||||
var union []string
|
||||
for _, metadata := range []map[string]interface{}{target, source} {
|
||||
ids, ok := normalizeQueueAdmissionIDs(metadata[MetadataQueueAdmissionIDs])
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
union = append(union, id)
|
||||
}
|
||||
}
|
||||
return union, true
|
||||
}
|
||||
|
||||
func normalizeQueueAdmissionIDs(raw interface{}) ([]string, bool) {
|
||||
if raw == nil {
|
||||
return nil, true
|
||||
}
|
||||
encoded, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal(encoded, &ids); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func autoMetadataEquivalent(target, source map[string]interface{}) bool {
|
||||
func comparableAutoMetadata(metadata map[string]interface{}) ([]byte, bool) {
|
||||
comparable := make(map[string]interface{}, len(metadata))
|
||||
for key, value := range metadata {
|
||||
if key == MetadataEntityReferences || key == MetadataContextFiles {
|
||||
if key == MetadataEntityReferences || key == MetadataContextFiles || key == MetadataQueueAdmissionIDs {
|
||||
continue
|
||||
}
|
||||
comparable[key] = value
|
||||
@@ -114,8 +114,13 @@ func mergeAutoMetadata(target, source map[string]interface{}) (map[string]interf
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
admissionIDs, ok := unionQueueAdmissionIDs(target, source)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
merged = setAutoMetadataList(merged, MetadataEntityReferences, references)
|
||||
merged = setAutoMetadataList(merged, MetadataContextFiles, contexts)
|
||||
merged = setAutoMetadataList(merged, MetadataQueueAdmissionIDs, admissionIDs)
|
||||
return merged, true
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +62,10 @@ func hasPlanCommentAdmission(metadata map[string]interface{}) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// mergeEntryMetadata returns a copy of the target metadata with
|
||||
// MetadataEntityReferences replaced by the union of both entries' references,
|
||||
// normalized and deduplicated by canonical ref. The key is dropped when the
|
||||
// union is empty. Returns ErrMergeReferenceOverflow when the union would
|
||||
// exceed the per-message reference cap.
|
||||
// mergeEntryMetadata returns a copy of the target metadata with additive
|
||||
// entity references and ordinary admission provenance replaced by their
|
||||
// normalized, deduplicated unions. Empty union keys are removed. Returns
|
||||
// ErrMergeReferenceOverflow when the entity-reference union exceeds its cap.
|
||||
func mergeEntryMetadata(target, source map[string]interface{}) (map[string]interface{}, error) {
|
||||
merged := make(map[string]interface{}, len(target)+1)
|
||||
for key, value := range target {
|
||||
@@ -78,9 +77,18 @@ func mergeEntryMetadata(target, source map[string]interface{}) (map[string]inter
|
||||
}
|
||||
if len(union) == 0 {
|
||||
delete(merged, MetadataEntityReferences)
|
||||
return merged, nil
|
||||
} else {
|
||||
merged[MetadataEntityReferences] = union
|
||||
}
|
||||
admissionIDs, ok := unionQueueAdmissionIDs(target, source)
|
||||
if !ok {
|
||||
return nil, ErrInvalidQueueAdmissionIDs
|
||||
}
|
||||
if len(admissionIDs) == 0 {
|
||||
delete(merged, MetadataQueueAdmissionIDs)
|
||||
} else {
|
||||
merged[MetadataQueueAdmissionIDs] = admissionIDs
|
||||
}
|
||||
merged[MetadataEntityReferences] = union
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
package messagequeue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const queueAdmissionReceiptSchema = `
|
||||
CREATE TABLE IF NOT EXISTS queue_admission_receipts (
|
||||
task_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
session_incarnation_id TEXT NOT NULL,
|
||||
client_queue_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
accepted_queue_id TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
admitted_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (task_id, session_id, session_incarnation_id, client_queue_id)
|
||||
)`
|
||||
|
||||
// LookupQueueAdmission checks the durable receipt for one identified request
|
||||
// without evaluating queue capacity or staged attachment ownership.
|
||||
func (r *sqliteRepository) LookupQueueAdmission(
|
||||
ctx context.Context,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID string,
|
||||
candidate *QueuedMessage,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if err := validateQueueAdmissionInput(identity, clientQueueID, candidate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
fingerprint, err := queueAdmissionFingerprint(identity, candidate)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
unlock := r.withSessionLock(identity.SessionID)
|
||||
defer unlock()
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("begin queue admission lookup tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := r.guardQueueAdmissionTx(ctx, tx, identity, nil); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
receipt, err := r.readQueueAdmissionReceiptTx(ctx, tx, identity, clientQueueID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if receipt == nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("commit queue admission lookup: %w", err)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
if receipt.Fingerprint != fingerprint {
|
||||
return nil, false, ErrQueueIDConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("commit queue admission replay lookup: %w", err)
|
||||
}
|
||||
return receipt.Message, true, nil
|
||||
}
|
||||
|
||||
// AdmitQueueMessage commits one ordinary caller-identified admission. The
|
||||
// receipt is written with the queue insertion or automatic fold, so a replay
|
||||
// can never repeat a queue mutation after the visible row has changed.
|
||||
func (r *sqliteRepository) AdmitQueueMessage(
|
||||
ctx context.Context,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID string,
|
||||
candidate *QueuedMessage,
|
||||
claim *QueueAttachmentClaim,
|
||||
maxPerSession int,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if err := validateQueueAdmissionInput(identity, clientQueueID, candidate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
fingerprint, err := queueAdmissionFingerprint(identity, candidate)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
admitted := cloneQueuedMessage(candidate)
|
||||
admitted.ID = clientQueueID
|
||||
unlock := r.withSessionLock(identity.SessionID)
|
||||
defer unlock()
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("begin queue admission tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := r.guardQueueAdmissionTx(ctx, tx, identity, policy); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
receipt, err := r.readQueueAdmissionReceiptTx(ctx, tx, identity, clientQueueID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if receipt != nil {
|
||||
if receipt.Fingerprint != fingerprint {
|
||||
return nil, false, ErrQueueIDConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("commit queue admission replay: %w", err)
|
||||
}
|
||||
return receipt.Message, true, nil
|
||||
}
|
||||
if err := r.rejectExistingQueueAdmissionIDTx(ctx, tx, admitted); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
accepted, err := r.admitQueueCandidateTx(ctx, tx, admitted, claim, maxPerSession, policy)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := r.insertQueueAdmissionReceiptTx(ctx, tx, identity, clientQueueID, fingerprint, accepted); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("commit queue admission: %w", err)
|
||||
}
|
||||
return accepted, false, nil
|
||||
}
|
||||
|
||||
func validateQueueAdmissionInput(
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID string,
|
||||
candidate *QueuedMessage,
|
||||
) error {
|
||||
if identity.TaskID == "" || identity.SessionID == "" || identity.SessionIncarnationID == "" {
|
||||
return ErrSessionIdentityMismatch
|
||||
}
|
||||
if clientQueueID == "" || len(clientQueueID) > MaxQueueAdmissionIDLength {
|
||||
return errors.New("client queue id is invalid")
|
||||
}
|
||||
if candidate == nil || candidate.TaskID != identity.TaskID || candidate.SessionID != identity.SessionID {
|
||||
return ErrSessionIdentityMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) guardQueueAdmissionTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
identity QueueSessionIdentity,
|
||||
policy *AutoMergePolicy,
|
||||
) error {
|
||||
if err := r.guardActiveTaskTx(ctx, tx, identity.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.lockSessionTx(ctx, tx, identity.SessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.guardSessionTx(ctx, tx, identity.SessionID, identity.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.validateSessionIdentityTx(ctx, tx, identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.validateAutoMergePolicyTx(ctx, tx, &identity, policy)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) readQueueAdmissionReceiptTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID string,
|
||||
) (*queueAdmissionReceipt, error) {
|
||||
query := `
|
||||
SELECT request_fingerprint, response_json
|
||||
FROM queue_admission_receipts
|
||||
WHERE task_id = ? AND session_id = ? AND session_incarnation_id = ? AND client_queue_id = ?`
|
||||
if r.db.DriverName() == postgresDriverName {
|
||||
query += postgresForUpdateSuffix
|
||||
}
|
||||
var fingerprint, responseJSON string
|
||||
err := tx.QueryRowxContext(ctx, r.db.Rebind(query), identity.TaskID, identity.SessionID,
|
||||
identity.SessionIncarnationID, clientQueueID).Scan(&fingerprint, &responseJSON)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read queue admission receipt: %w", err)
|
||||
}
|
||||
var message QueuedMessage
|
||||
if err := json.Unmarshal([]byte(responseJSON), &message); err != nil {
|
||||
return nil, fmt.Errorf("decode queue admission receipt: %w", err)
|
||||
}
|
||||
return &queueAdmissionReceipt{Fingerprint: fingerprint, Message: &message}, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) rejectExistingQueueAdmissionIDTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
candidate *QueuedMessage,
|
||||
) error {
|
||||
var present bool
|
||||
err := tx.GetContext(ctx, &present, r.db.Rebind(`
|
||||
SELECT EXISTS (SELECT 1 FROM queued_messages WHERE id = ?)
|
||||
`), candidate.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check queue admission id: %w", err)
|
||||
}
|
||||
if present {
|
||||
return ErrQueueIDConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) admitQueueCandidateTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
candidate *QueuedMessage,
|
||||
claim *QueueAttachmentClaim,
|
||||
maxPerSession int,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, error) {
|
||||
if candidate.QueuedAt.IsZero() {
|
||||
candidate.QueuedAt = time.Now().UTC()
|
||||
}
|
||||
if err := r.ensureQueueCapacityTx(ctx, tx, candidate.SessionID, maxPerSession); err == nil {
|
||||
return r.insertQueueCandidateTx(ctx, tx, candidate, claim, maxPerSession, policy)
|
||||
} else if !errors.Is(err, ErrQueueFull) {
|
||||
return nil, err
|
||||
}
|
||||
if claim != nil && len(claim.IDs) > 0 {
|
||||
return nil, ErrQueueFull
|
||||
}
|
||||
if policy == nil || !policy.Enabled {
|
||||
return nil, ErrQueueFull
|
||||
}
|
||||
return r.admitFullQueueCandidateTx(ctx, tx, candidate, claim)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) insertQueueCandidateTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
candidate *QueuedMessage,
|
||||
claim *QueueAttachmentClaim,
|
||||
maxPerSession int,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, error) {
|
||||
if err := claimOptionalMessageAttachmentsTx(
|
||||
ctx, tx, &QueueSessionIdentity{TaskID: candidate.TaskID, SessionID: candidate.SessionID},
|
||||
claim, candidate.TaskID, candidate.SessionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if candidate.QueuedAt.IsZero() {
|
||||
candidate.QueuedAt = time.Now().UTC()
|
||||
}
|
||||
if err := insertQueuedMessageInTransaction(ctx, tx, r.db, candidate, maxPerSession); err != nil {
|
||||
if isQueuedMessageIDViolation(err) {
|
||||
return nil, ErrQueueIDConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil || !policy.Enabled {
|
||||
return candidate, nil
|
||||
}
|
||||
return r.mergeInsertedAdmissionCandidateTx(ctx, tx, candidate)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) mergeInsertedAdmissionCandidateTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
candidate *QueuedMessage,
|
||||
) (*QueuedMessage, error) {
|
||||
target, err := readMergeTarget(ctx, r, tx, candidate.SessionID, candidate.Position)
|
||||
if errors.Is(err, ErrNoMergeTarget) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, compatible := buildAutoMergedEntry(target, candidate)
|
||||
if !compatible {
|
||||
return candidate, nil
|
||||
}
|
||||
blocked, err := r.editLeaseBlocksEntryTx(ctx, tx, candidate.SessionID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := applyMergeWrites(ctx, r, tx, target, candidate, values.content, values.attachments, values.metadata, candidate.SessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target.Content = values.content
|
||||
target.Attachments = values.attachments
|
||||
target.Metadata = values.metadata
|
||||
target.QueuedAt = values.queuedAt
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) admitFullQueueCandidateTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
candidate *QueuedMessage,
|
||||
claim *QueueAttachmentClaim,
|
||||
) (*QueuedMessage, error) {
|
||||
target, storedContent, storedAttachmentsJSON, storedMetadataJSON, err := r.scanTailWithRawJSON(ctx, tx, candidate.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil {
|
||||
return nil, ErrQueueFull
|
||||
}
|
||||
values, compatible := buildAutoMergedEntry(target, candidate)
|
||||
if !compatible {
|
||||
return nil, ErrQueueFull
|
||||
}
|
||||
blocked, err := r.editLeaseBlocksEntryTx(ctx, tx, candidate.SessionID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
return nil, ErrQueueFull
|
||||
}
|
||||
if err := claimOptionalMessageAttachmentsTx(
|
||||
ctx, tx, &QueueSessionIdentity{TaskID: candidate.TaskID, SessionID: candidate.SessionID},
|
||||
claim, candidate.TaskID, candidate.SessionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := updateAdmissionMergeTargetTx(
|
||||
ctx, r, tx, target, storedContent, storedAttachmentsJSON, storedMetadataJSON, values,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func updateAdmissionMergeTargetTx(
|
||||
ctx context.Context,
|
||||
r *sqliteRepository,
|
||||
tx *sqlx.Tx,
|
||||
target *QueuedMessage,
|
||||
storedContent, storedAttachmentsJSON, storedMetadataJSON string,
|
||||
values *autoMergedValues,
|
||||
) error {
|
||||
attachmentsJSON, err := marshalAttachments(values.attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadataJSON, err := marshalMetadata(values.metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE queued_messages
|
||||
SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ?
|
||||
WHERE id = ? AND session_id = ? AND position = ? AND content = ?
|
||||
AND attachments_json = ? AND metadata_json = ?
|
||||
`), values.content, attachmentsJSON, metadataJSON, values.queuedAt, target.ID,
|
||||
target.SessionID, target.Position, storedContent, storedAttachmentsJSON, storedMetadataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update full queue admission target: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrQueueChanged
|
||||
}
|
||||
target.Content = values.content
|
||||
target.Attachments = values.attachments
|
||||
target.Metadata = values.metadata
|
||||
target.QueuedAt = values.queuedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) insertQueueAdmissionReceiptTx(
|
||||
ctx context.Context,
|
||||
tx *sqlx.Tx,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID, fingerprint string,
|
||||
message *QueuedMessage,
|
||||
) error {
|
||||
if message == nil {
|
||||
return errors.New("queue admission message is nil")
|
||||
}
|
||||
snapshot := queueAdmissionResponseSnapshot(message)
|
||||
responseJSON, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode queue admission receipt: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO queue_admission_receipts
|
||||
(task_id, session_id, session_incarnation_id, client_queue_id,
|
||||
request_fingerprint, accepted_queue_id, response_json, admitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), identity.TaskID, identity.SessionID, identity.SessionIncarnationID, clientQueueID,
|
||||
fingerprint, message.ID, string(responseJSON), time.Now().UTC())
|
||||
if err != nil {
|
||||
if isQueueAdmissionReceiptViolation(err) {
|
||||
return ErrQueueIDConflict
|
||||
}
|
||||
return fmt.Errorf("insert queue admission receipt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isQueueAdmissionReceiptViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "queue_admission_receipts")
|
||||
}
|
||||
return strings.Contains(err.Error(), "UNIQUE constraint failed: queue_admission_receipts")
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package messagequeue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *memoryRepository) LookupQueueAdmission(
|
||||
_ context.Context,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID string,
|
||||
candidate *QueuedMessage,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if err := validateQueueAdmissionInput(identity, clientQueueID, candidate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
fingerprint, err := queueAdmissionFingerprint(identity, candidate)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.bindIdentityLocked(identity); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
key := queueAdmissionKey{
|
||||
TaskID: identity.TaskID, SessionID: identity.SessionID,
|
||||
SessionIncarnationID: identity.SessionIncarnationID, ClientQueueID: clientQueueID,
|
||||
}
|
||||
return r.replayAdmissionLocked(key, fingerprint)
|
||||
}
|
||||
|
||||
func (r *memoryRepository) AdmitQueueMessage(
|
||||
_ context.Context,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID string,
|
||||
candidate *QueuedMessage,
|
||||
claim *QueueAttachmentClaim,
|
||||
maxPerSession int,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if err := validateQueueAdmissionInput(identity, clientQueueID, candidate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
fingerprint, err := queueAdmissionFingerprint(identity, candidate)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.bindIdentityLocked(identity); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
key := queueAdmissionKey{
|
||||
TaskID: identity.TaskID, SessionID: identity.SessionID,
|
||||
SessionIncarnationID: identity.SessionIncarnationID, ClientQueueID: clientQueueID,
|
||||
}
|
||||
if replay, found, err := r.replayAdmissionLocked(key, fingerprint); found {
|
||||
return replay, true, err
|
||||
}
|
||||
if r.queueIDExistsLocked(clientQueueID) {
|
||||
return nil, false, ErrQueueIDConflict
|
||||
}
|
||||
message := cloneQueuedMessage(candidate)
|
||||
message.ID = clientQueueID
|
||||
return r.admitNewAdmissionLocked(
|
||||
identity, key, fingerprint, message, claim, maxPerSession, policy,
|
||||
)
|
||||
}
|
||||
|
||||
func (r *memoryRepository) replayAdmissionLocked(
|
||||
key queueAdmissionKey,
|
||||
fingerprint string,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
receipt, ok := r.admissionReceipts[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
if receipt.Fingerprint != fingerprint {
|
||||
return nil, false, ErrQueueIDConflict
|
||||
}
|
||||
return cloneQueuedMessage(receipt.Message), true, nil
|
||||
}
|
||||
|
||||
func (r *memoryRepository) queueIDExistsLocked(clientQueueID string) bool {
|
||||
for _, entries := range r.entries {
|
||||
for _, entry := range entries {
|
||||
if entry.ID == clientQueueID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *memoryRepository) admitNewAdmissionLocked(
|
||||
identity QueueSessionIdentity,
|
||||
key queueAdmissionKey,
|
||||
fingerprint string,
|
||||
message *QueuedMessage,
|
||||
claim *QueueAttachmentClaim,
|
||||
maxPerSession int,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if message.QueuedAt.IsZero() {
|
||||
message.QueuedAt = time.Now().UTC()
|
||||
}
|
||||
if len(r.entries[identity.SessionID]) >= maxPerSession && maxPerSession > 0 {
|
||||
if claim != nil && len(claim.IDs) > 0 {
|
||||
return nil, false, ErrQueueFull
|
||||
}
|
||||
return r.admitFullAdmissionLocked(key, fingerprint, message, policy)
|
||||
}
|
||||
if claim != nil && len(claim.IDs) > 0 {
|
||||
return nil, false, ErrQueueAdmissionUnavailable
|
||||
}
|
||||
if err := r.insertLocked(message, maxPerSession); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
accepted, err := r.mergeInsertedAdmissionLocked(identity.SessionID, message, policy)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return r.rememberAdmissionLocked(key, fingerprint, accepted)
|
||||
}
|
||||
|
||||
func (r *memoryRepository) admitFullAdmissionLocked(
|
||||
key queueAdmissionKey,
|
||||
fingerprint string,
|
||||
message *QueuedMessage,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if policy == nil || !policy.Enabled {
|
||||
return nil, false, ErrQueueFull
|
||||
}
|
||||
merged, didMerge, err := r.autoMergeCandidateIntoAboveLocked(message)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !didMerge {
|
||||
return nil, false, ErrQueueFull
|
||||
}
|
||||
return r.rememberAdmissionLocked(key, fingerprint, merged)
|
||||
}
|
||||
|
||||
func (r *memoryRepository) mergeInsertedAdmissionLocked(
|
||||
sessionID string,
|
||||
message *QueuedMessage,
|
||||
policy *AutoMergePolicy,
|
||||
) (*QueuedMessage, error) {
|
||||
if policy == nil || !policy.Enabled {
|
||||
return message, nil
|
||||
}
|
||||
merged, didMerge, err := r.autoMergeIntoAboveLocked(sessionID, message.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if didMerge {
|
||||
return merged, nil
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (r *memoryRepository) rememberAdmissionLocked(
|
||||
key queueAdmissionKey,
|
||||
fingerprint string,
|
||||
message *QueuedMessage,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if message == nil {
|
||||
return nil, false, errors.New("queue admission message is nil")
|
||||
}
|
||||
snapshot := queueAdmissionResponseSnapshot(message)
|
||||
r.admissionReceipts[key] = queueAdmissionReceipt{Fingerprint: fingerprint, Message: snapshot}
|
||||
return cloneQueuedMessage(message), false, nil
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package messagequeue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSQLiteQueueAdmissionReplaysAfterQueueRowRemoval(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "incarnation-1",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
ctx := context.Background()
|
||||
|
||||
first, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-1", "prompt", "model", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
require.Equal(t, "client-1", first.ID)
|
||||
|
||||
removed, err := repo.DeleteByIDForSession(ctx, identity, first.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, removed.Removed, 1)
|
||||
|
||||
replayed, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-1", "prompt", "model", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, replay)
|
||||
require.Equal(t, first.ID, replayed.ID)
|
||||
entries, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, entries)
|
||||
|
||||
_, replay, err = service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-1", "changed", "model", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrQueueIDConflict)
|
||||
require.False(t, replay)
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.2
|
||||
func TestSQLiteQueueAdmissionReceiptSurvivesRepositoryRestart(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "incarnation-1",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
ctx := context.Background()
|
||||
first, _, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-restart", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = repo.DeleteByIDForSession(ctx, identity, first.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
restarted, err := NewSQLiteRepository(sqliteRepo.db, sqliteRepo.ro)
|
||||
require.NoError(t, err)
|
||||
restartedService := NewService(restarted, 10, logger.Default())
|
||||
replayed, replay, err := restartedService.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-restart", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, replay)
|
||||
require.Equal(t, first.ID, replayed.ID)
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.3
|
||||
func TestMemoryQueueAdmissionBindsReceiptToSessionIncarnation(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
firstIdentity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "memory:session",
|
||||
}
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
ctx := context.Background()
|
||||
first, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, firstIdentity, "client-reset", "first", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
_, err = repo.DeleteByIDForSession(ctx, firstIdentity, first.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondIdentity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "memory:session-v2",
|
||||
}
|
||||
memoryRepo := repo.(*memoryRepository)
|
||||
memoryRepo.mu.Lock()
|
||||
memoryRepo.identities[secondIdentity.SessionID] = secondIdentity
|
||||
_, oldReceipt := memoryRepo.admissionReceipts[queueAdmissionKey{
|
||||
TaskID: firstIdentity.TaskID, SessionID: firstIdentity.SessionID,
|
||||
SessionIncarnationID: firstIdentity.SessionIncarnationID, ClientQueueID: "client-reset",
|
||||
}]
|
||||
memoryRepo.mu.Unlock()
|
||||
require.True(t, oldReceipt, "changing the incarnation must retain the old receipt until task/session deletion")
|
||||
second, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, secondIdentity, "client-reset", "second", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
require.Equal(t, "second", second.Content)
|
||||
}
|
||||
|
||||
func TestMemoryQueueAdmissionConflictIsNotReplay(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "conflict-task", SessionID: "conflict-session", SessionIncarnationID: "memory:conflict-session",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
ctx := context.Background()
|
||||
_, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-conflict", "first", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
|
||||
_, replay, err = service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-conflict", "changed", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrQueueIDConflict)
|
||||
require.False(t, replay)
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.2
|
||||
func TestQueueAdmissionConcurrentReplayIsAtMostOnce(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "incarnation-1",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
otherRepo, err := NewSQLiteRepository(sqliteRepo.db, sqliteRepo.ro)
|
||||
require.NoError(t, err)
|
||||
services := []*Service{
|
||||
NewService(repo, 10, logger.Default()),
|
||||
NewService(otherRepo, 10, logger.Default()),
|
||||
}
|
||||
ctx := context.Background()
|
||||
start := make(chan struct{})
|
||||
type result struct {
|
||||
message *QueuedMessage
|
||||
replay bool
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 8)
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 8; index++ {
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
<-start
|
||||
message, replay, err := services[index%len(services)].QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-concurrent", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
results <- result{message: message, replay: replay, err: err}
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(results)
|
||||
var first *QueuedMessage
|
||||
initialAdmissions := 0
|
||||
for item := range results {
|
||||
require.NoError(t, item.err)
|
||||
require.NotNil(t, item.message)
|
||||
if !item.replay {
|
||||
initialAdmissions++
|
||||
first = item.message
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, initialAdmissions)
|
||||
entries, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, first.ID, entries[0].ID)
|
||||
}
|
||||
|
||||
func TestSQLiteQueueAdmissionAutoMergeStoresSurvivingQueueID(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "incarnation-1",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 1, logger.Default())
|
||||
service.SetAutoMergePolicy(true, 1)
|
||||
ctx := context.Background()
|
||||
first, _, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-first", "first", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
merged, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-second", "second", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
require.Equal(t, first.ID, merged.ID)
|
||||
require.Equal(t, "first\n\nsecond", merged.Content)
|
||||
entries, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
oldQueuedAt := time.Now().UTC().Add(-time.Hour)
|
||||
_, err = sqliteRepo.db.Exec(
|
||||
`UPDATE queued_messages SET queued_at = ? WHERE id = ?`, oldQueuedAt, first.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
entries, err = repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
oldQueuedAt = entries[0].QueuedAt
|
||||
|
||||
merged, replay, err = service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-third", "third", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
require.True(t, merged.QueuedAt.After(oldQueuedAt))
|
||||
entries, err = repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, merged.QueuedAt, entries[0].QueuedAt)
|
||||
|
||||
_, err = repo.DeleteByIDForSession(ctx, identity, first.ID)
|
||||
require.NoError(t, err)
|
||||
replayed, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-third", "third", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, replay)
|
||||
require.Equal(t, merged.ID, replayed.ID)
|
||||
require.Equal(t, merged.QueuedAt, replayed.QueuedAt)
|
||||
}
|
||||
|
||||
func createTestMessageAttachmentsTable(t *testing.T, sqliteRepo *sqliteRepository) {
|
||||
t.Helper()
|
||||
_, err := sqliteRepo.db.Exec(`
|
||||
CREATE TABLE task_message_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL DEFAULT '',
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
queue_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
delivery_mode TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)`)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestQueueAdmissionStagedAttachmentFullQueueRejects(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
createTestMessageAttachmentsTable(t, sqliteRepo)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "staged-task", SessionID: "staged-session", SessionIncarnationID: "staged-incarnation",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 1, logger.Default())
|
||||
service.SetAutoMergePolicy(true, 1)
|
||||
ctx := context.Background()
|
||||
first, _, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-first", "first", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
entriesBefore, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entriesBefore, 1)
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = sqliteRepo.db.Exec(sqliteRepo.db.Rebind(`
|
||||
INSERT INTO task_message_attachments
|
||||
(id, owner_id, workspace_id, size_bytes, storage_key, state, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), "attachment-full", "owner", "workspace", 5, "storage-full", "staged", now.Add(time.Hour), now, now)
|
||||
require.NoError(t, err)
|
||||
attachments := []MessageAttachment{{
|
||||
Type: "resource", Data: "inline-data", AttachmentID: "attachment-full",
|
||||
Name: "full.txt", MimeType: "text/plain", SizeBytes: 5, DeliveryMode: "path",
|
||||
}}
|
||||
claim := &QueueAttachmentClaim{OwnerID: "owner", WorkspaceID: "workspace", IDs: []string{"attachment-full"}}
|
||||
|
||||
_, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-full", "second", "", QueuedByUser, false, attachments, nil, claim,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrQueueFull)
|
||||
require.False(t, replay)
|
||||
entriesAfter, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entriesBefore, entriesAfter)
|
||||
|
||||
var state, taskID, sessionID string
|
||||
require.NoError(t, sqliteRepo.db.QueryRowx(
|
||||
`SELECT state, task_id, session_id FROM task_message_attachments WHERE id = ?`,
|
||||
"attachment-full",
|
||||
).Scan(&state, &taskID, &sessionID))
|
||||
require.Equal(t, "staged", state)
|
||||
require.Empty(t, taskID)
|
||||
require.Empty(t, sessionID)
|
||||
|
||||
var receipts int
|
||||
require.NoError(t, sqliteRepo.db.Get(&receipts, sqliteRepo.db.Rebind(`
|
||||
SELECT COUNT(*) FROM queue_admission_receipts
|
||||
WHERE task_id = ? AND session_id = ? AND session_incarnation_id = ? AND client_queue_id = ?
|
||||
`), identity.TaskID, identity.SessionID, identity.SessionIncarnationID, "client-full"))
|
||||
require.Zero(t, receipts)
|
||||
require.Equal(t, first.ID, entriesAfter[0].ID)
|
||||
}
|
||||
|
||||
func TestMemoryQueueAdmissionStagedAttachmentFullQueueRejects(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "staged-task", SessionID: "staged-session", SessionIncarnationID: "staged-incarnation",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 1, logger.Default())
|
||||
service.SetAutoMergePolicy(true, 1)
|
||||
ctx := context.Background()
|
||||
first, _, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-first", "first", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
entriesBefore, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
|
||||
claim := &QueueAttachmentClaim{OwnerID: "owner", WorkspaceID: "workspace", IDs: []string{"attachment-full"}}
|
||||
_, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-full", "second", "", QueuedByUser, false,
|
||||
[]MessageAttachment{{Type: "resource", AttachmentID: "attachment-full"}}, nil, claim,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrQueueFull)
|
||||
require.False(t, replay)
|
||||
entriesAfter, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entriesBefore, entriesAfter)
|
||||
require.Equal(t, first.ID, entriesAfter[0].ID)
|
||||
|
||||
memoryRepo := repo.(*memoryRepository)
|
||||
memoryRepo.mu.Lock()
|
||||
_, stored := memoryRepo.admissionReceipts[queueAdmissionKey{
|
||||
TaskID: identity.TaskID, SessionID: identity.SessionID,
|
||||
SessionIncarnationID: identity.SessionIncarnationID, ClientQueueID: "client-full",
|
||||
}]
|
||||
memoryRepo.mu.Unlock()
|
||||
require.False(t, stored)
|
||||
}
|
||||
|
||||
func TestMemoryQueueAdmissionRejectsStagedClaimWithoutAttachmentStore(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "staged-memory-task", SessionID: "staged-memory-session", SessionIncarnationID: "memory:staged-memory-session",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
ctx := context.Background()
|
||||
attachments := []MessageAttachment{{Type: "resource", AttachmentID: "attachment-memory"}}
|
||||
claim := &QueueAttachmentClaim{OwnerID: "owner", WorkspaceID: "workspace", IDs: []string{"attachment-memory"}}
|
||||
|
||||
for range 2 {
|
||||
_, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-staged-memory", "second", "", QueuedByUser, false, attachments, nil, claim,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrQueueAdmissionUnavailable)
|
||||
require.False(t, replay)
|
||||
}
|
||||
entries, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, entries)
|
||||
memoryRepo := repo.(*memoryRepository)
|
||||
memoryRepo.mu.Lock()
|
||||
_, stored := memoryRepo.admissionReceipts[queueAdmissionKey{
|
||||
TaskID: identity.TaskID, SessionID: identity.SessionID,
|
||||
SessionIncarnationID: identity.SessionIncarnationID, ClientQueueID: "client-staged-memory",
|
||||
}]
|
||||
memoryRepo.mu.Unlock()
|
||||
require.False(t, stored)
|
||||
}
|
||||
|
||||
func TestMemoryQueueAdmissionAutoMergeAdvancesQueuedAtAndReplayKeepsIt(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "memory-task", SessionID: "memory-session", SessionIncarnationID: "memory-incarnation",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 1, logger.Default())
|
||||
service.SetAutoMergePolicy(true, 1)
|
||||
ctx := context.Background()
|
||||
first, _, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-first", "first", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
oldQueuedAt := time.Now().UTC().Add(-time.Hour)
|
||||
memoryRepo := repo.(*memoryRepository)
|
||||
memoryRepo.mu.Lock()
|
||||
memoryRepo.entries[identity.SessionID][0].QueuedAt = oldQueuedAt
|
||||
memoryRepo.mu.Unlock()
|
||||
|
||||
merged, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-second", "second", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
require.Equal(t, first.ID, merged.ID)
|
||||
require.True(t, merged.QueuedAt.After(oldQueuedAt))
|
||||
entries, err := repo.ListBySession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, merged.QueuedAt, entries[0].QueuedAt)
|
||||
|
||||
_, err = repo.DeleteByIDForSession(ctx, identity, first.ID)
|
||||
require.NoError(t, err)
|
||||
replayed, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-second", "second", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, replay)
|
||||
require.Equal(t, merged.QueuedAt, replayed.QueuedAt)
|
||||
}
|
||||
|
||||
func TestQueueAdmissionAttachmentReplay(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
createTestMessageAttachmentsTable(t, sqliteRepo)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "attachment-task", SessionID: "attachment-session", SessionIncarnationID: "attachment-incarnation",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
now := time.Now().UTC()
|
||||
_, err := sqliteRepo.db.Exec(sqliteRepo.db.Rebind(`
|
||||
INSERT INTO task_message_attachments
|
||||
(id, owner_id, workspace_id, size_bytes, storage_key, state, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), "attachment-replay", "owner", "workspace", 5, "storage-replay", "staged", now.Add(time.Hour), now, now)
|
||||
require.NoError(t, err)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
attachments := []MessageAttachment{{
|
||||
Type: "resource", Data: "inline-data", AttachmentID: "attachment-replay",
|
||||
Name: "replay.txt", MimeType: "text/plain", SizeBytes: 5, DeliveryMode: "path",
|
||||
}}
|
||||
claim := &QueueAttachmentClaim{OwnerID: "owner", WorkspaceID: "workspace", IDs: []string{"attachment-replay"}}
|
||||
ctx := context.Background()
|
||||
first, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-attachment", "prompt", "", QueuedByUser, false, attachments, nil, claim,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
require.Equal(t, "client-attachment", first.ID)
|
||||
var state, taskID, sessionID string
|
||||
var updatedAt time.Time
|
||||
require.NoError(t, sqliteRepo.db.QueryRowx(
|
||||
`SELECT state, task_id, session_id, updated_at FROM task_message_attachments WHERE id = ?`,
|
||||
"attachment-replay",
|
||||
).Scan(&state, &taskID, &sessionID, &updatedAt))
|
||||
require.Equal(t, "claimed", state)
|
||||
require.Equal(t, identity.TaskID, taskID)
|
||||
require.Equal(t, identity.SessionID, sessionID)
|
||||
|
||||
_, err = repo.DeleteByIDForSession(ctx, identity, first.ID)
|
||||
require.NoError(t, err)
|
||||
replayed, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-attachment", "prompt", "", QueuedByUser, false, attachments, nil, claim,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, replay)
|
||||
require.Equal(t, first.ID, replayed.ID)
|
||||
require.Empty(t, replayed.Attachments[0].Data)
|
||||
var replayUpdatedAt time.Time
|
||||
require.NoError(t, sqliteRepo.db.QueryRowx(
|
||||
`SELECT updated_at FROM task_message_attachments WHERE id = ?`,
|
||||
"attachment-replay",
|
||||
).Scan(&replayUpdatedAt))
|
||||
require.Equal(t, updatedAt, replayUpdatedAt)
|
||||
}
|
||||
|
||||
func TestQueueAdmissionLifecycleCleanup(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "task", SessionID: "session", SessionIncarnationID: "incarnation-1",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
ctx := context.Background()
|
||||
_, _, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-lifecycle", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = repo.DeleteAllBySessionForIdentity(ctx, identity)
|
||||
require.NoError(t, err)
|
||||
_, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-lifecycle", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, replay)
|
||||
|
||||
_, err = repo.PurgeSession(ctx, identity.SessionID)
|
||||
require.NoError(t, err)
|
||||
_, replay, err = service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-lifecycle", "new prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, replay)
|
||||
}
|
||||
@@ -81,9 +81,10 @@ func TestRepository_AutoMergeIntoAbove_PreservesIdentityAndCombinesAdditiveData(
|
||||
SessionID: "session", TaskID: "task", Content: "first", Model: "model", PlanMode: true, QueuedBy: "alice",
|
||||
Attachments: []MessageAttachment{{AttachmentID: "target-file", Name: "target.txt", SizeBytes: 6}},
|
||||
Metadata: map[string]interface{}{
|
||||
"route": map[string]interface{}{"attempt": 1, "tags": []string{"one"}},
|
||||
MetadataEntityReferences: entityRefs("1"),
|
||||
MetadataContextFiles: []apiv1.ContextFileMeta{{Path: "a.go", Name: "a.go"}},
|
||||
"route": map[string]interface{}{"attempt": 1, "tags": []string{"one"}},
|
||||
MetadataEntityReferences: entityRefs("1"),
|
||||
MetadataContextFiles: []apiv1.ContextFileMeta{{Path: "a.go", Name: "a.go"}},
|
||||
MetadataQueueAdmissionIDs: []string{"client-first"},
|
||||
},
|
||||
})
|
||||
source := insertAutoMergeEntry(t, repo, QueuedMessage{
|
||||
@@ -96,6 +97,7 @@ func TestRepository_AutoMergeIntoAbove_PreservesIdentityAndCombinesAdditiveData(
|
||||
map[string]interface{}{"path": "a.go", "name": "a.go"},
|
||||
map[string]interface{}{"path": "dir", "name": "dir", "is_directory": isDirectory},
|
||||
},
|
||||
MetadataQueueAdmissionIDs: []interface{}{"client-first", "client-second"},
|
||||
},
|
||||
})
|
||||
targetIdentity := autoMergeIdentity(*target)
|
||||
@@ -123,10 +125,28 @@ func TestRepository_AutoMergeIntoAbove_PreservesIdentityAndCombinesAdditiveData(
|
||||
if !ok || len(contexts) != 2 || contexts[0].Path != "a.go" || contexts[1].Path != "dir" {
|
||||
t.Errorf("context union = %+v, valid=%v", contexts, ok)
|
||||
}
|
||||
admissionIDs, ok := normalizeQueueAdmissionIDs(merged.Metadata[MetadataQueueAdmissionIDs])
|
||||
if !ok || !reflect.DeepEqual(admissionIDs, []string{"client-first", "client-second"}) {
|
||||
t.Errorf("admission ID union = %v, valid=%v", admissionIDs, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEntryMetadataPreservesQueueAdmissionIDs(t *testing.T) {
|
||||
merged, err := mergeEntryMetadata(
|
||||
map[string]interface{}{MetadataQueueAdmissionIDs: []string{"client-first"}},
|
||||
map[string]interface{}{MetadataQueueAdmissionIDs: []interface{}{"client-second", "client-first"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("merge metadata: %v", err)
|
||||
}
|
||||
ids, ok := normalizeQueueAdmissionIDs(merged[MetadataQueueAdmissionIDs])
|
||||
if !ok || !reflect.DeepEqual(ids, []string{"client-first", "client-second"}) {
|
||||
t.Fatalf("admission ID union = %v, valid=%v", ids, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_AutoMergeIntoAbove_AgentSameSender(t *testing.T) {
|
||||
for _, tt := range autoMergeRepositoryFactories() {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -25,6 +25,7 @@ type memoryRepository struct {
|
||||
autoRunIncarnation map[string]string
|
||||
autoMergeOverrides map[QueueSessionIdentity]AutoMergeOverride
|
||||
identities map[string]QueueSessionIdentity
|
||||
admissionReceipts map[queueAdmissionKey]queueAdmissionReceipt
|
||||
statusGeneration map[string]int64
|
||||
authority func(context.Context, string, string) (QueueSessionIdentity, error)
|
||||
}
|
||||
@@ -44,6 +45,7 @@ func NewMemoryRepository() Repository {
|
||||
autoMergeOverrides: make(map[QueueSessionIdentity]AutoMergeOverride),
|
||||
statusGeneration: make(map[string]int64),
|
||||
identities: make(map[string]QueueSessionIdentity),
|
||||
admissionReceipts: make(map[queueAdmissionKey]queueAdmissionReceipt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +69,11 @@ func (r *memoryRepository) clearSessionStateLocked(sessionID string) {
|
||||
delete(r.autoRunIncarnation, sessionID)
|
||||
delete(r.statusGeneration, sessionID)
|
||||
delete(r.identities, sessionID)
|
||||
for key := range r.admissionReceipts {
|
||||
if key.SessionID == sessionID {
|
||||
delete(r.admissionReceipts, key)
|
||||
}
|
||||
}
|
||||
for identity := range r.autoMergeOverrides {
|
||||
if identity.SessionID == sessionID {
|
||||
delete(r.autoMergeOverrides, identity)
|
||||
@@ -208,6 +215,11 @@ func (r *memoryRepository) PurgeTask(_ context.Context, taskID string) (int, err
|
||||
affectedSessions[sessionID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for key := range r.admissionReceipts {
|
||||
if key.TaskID == taskID {
|
||||
delete(r.admissionReceipts, key)
|
||||
}
|
||||
}
|
||||
for sessionID := range affectedSessions {
|
||||
r.sessionGeneration[sessionID]++
|
||||
}
|
||||
@@ -2166,6 +2178,11 @@ func (r *memoryRepository) PurgeSession(_ context.Context, sessionID string) (in
|
||||
defer r.mu.Unlock()
|
||||
removed := len(r.entries[sessionID])
|
||||
delete(r.entries, sessionID)
|
||||
for key := range r.admissionReceipts {
|
||||
if key.SessionID == sessionID {
|
||||
delete(r.admissionReceipts, key)
|
||||
}
|
||||
}
|
||||
delete(r.pendingMoves, sessionID)
|
||||
delete(r.autoRun, sessionID)
|
||||
r.sessionGeneration[sessionID]++
|
||||
|
||||
+28
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
func TestPostgresRepository_DurableQueueRecoveryTables(t *testing.T) {
|
||||
@@ -164,6 +166,32 @@ func TestPostgresRepository_DurableQueueRecoveryTables(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-QUEUE-ADMISSION-001.2
|
||||
func TestPostgresQueueAdmissionReceiptSurvivesRowRemoval(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestPostgresRepo(t)
|
||||
identity := QueueSessionIdentity{
|
||||
TaskID: "admission-task", SessionID: "admission-session", SessionIncarnationID: "admission-incarnation",
|
||||
}
|
||||
seedQueueSessionIdentity(t, repo, identity)
|
||||
service := NewService(repo, 10, logger.Default())
|
||||
first, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-postgres", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
if err != nil || replay {
|
||||
t.Fatalf("first admission: message=%+v replay=%t err=%v", first, replay, err)
|
||||
}
|
||||
if _, err := repo.DeleteByIDForSession(ctx, identity, first.ID); err != nil {
|
||||
t.Fatalf("remove queue row: %v", err)
|
||||
}
|
||||
replayed, replay, err := service.QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx, identity, "client-postgres", "prompt", "", QueuedByUser, false, nil, nil, nil,
|
||||
)
|
||||
if err != nil || !replay || replayed.ID != first.ID {
|
||||
t.Fatalf("replay: message=%+v replay=%t err=%v", replayed, replay, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAttachmentCleanupMigratesAndTransfersCurrentSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestPostgresRepo(t).(*sqliteRepository)
|
||||
|
||||
@@ -487,6 +487,9 @@ func (r *sqliteRepository) initSchema() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.db.Exec(queueAdmissionReceiptSchema); err != nil {
|
||||
return fmt.Errorf("create queue admission receipts: %w", err)
|
||||
}
|
||||
// Existing installations may have the pre-audit shape; fresh installs
|
||||
// already get both audit columns from CREATE TABLE above, so these replay
|
||||
// as duplicate-column errors there.
|
||||
@@ -1707,6 +1710,9 @@ func purgeQueueRowSessions(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, taskID
|
||||
}
|
||||
|
||||
func ensureTaskPurgeRecoverySchemas(ctx context.Context, tx *sqlx.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, queueAdmissionReceiptSchema); err != nil {
|
||||
return fmt.Errorf("ensure queue admission receipt schema: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, queueDispatchRecoverySchema); err != nil {
|
||||
return fmt.Errorf("ensure task purge dispatch recovery schema: %w", err)
|
||||
}
|
||||
@@ -1866,6 +1872,11 @@ func PurgeTaskInTransaction(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, taskI
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge queued task entries rows affected: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, db.Rebind(`
|
||||
DELETE FROM queue_admission_receipts WHERE task_id = ?
|
||||
`), taskID); err != nil {
|
||||
return 0, fmt.Errorf("purge task queue admission receipts: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, db.Rebind(`DELETE FROM pending_moves WHERE task_id = ?`), taskID); err != nil {
|
||||
return 0, fmt.Errorf("purge pending task moves: %w", err)
|
||||
}
|
||||
@@ -1926,6 +1937,11 @@ func PurgeSessionInTransaction(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, se
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge queued session entries rows affected: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, db.Rebind(`
|
||||
DELETE FROM queue_admission_receipts WHERE session_id = ?
|
||||
`), sessionID); err != nil {
|
||||
return 0, fmt.Errorf("purge session queue admission receipts: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, db.Rebind(`
|
||||
DELETE FROM pending_moves WHERE session_id = ?
|
||||
`), sessionID); err != nil {
|
||||
@@ -5279,6 +5295,11 @@ func (r *sqliteRepository) PurgeSession(ctx context.Context, sessionID string) (
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge session queue rows affected: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
DELETE FROM queue_admission_receipts WHERE session_id = ?
|
||||
`), sessionID); err != nil {
|
||||
return 0, fmt.Errorf("purge session queue admission receipts: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
DELETE FROM pending_moves WHERE session_id = ?
|
||||
`), sessionID); err != nil {
|
||||
|
||||
@@ -1058,6 +1058,90 @@ func (s *Service) QueueMessageWithMetadataForSession(
|
||||
)
|
||||
}
|
||||
|
||||
// QueueMessageWithMetadataForSessionWithClientQueueID admits an ordinary
|
||||
// browser queue message with a durable caller-owned replay identity. The
|
||||
// receipt and any queue insertion, fold, or attachment claim share one
|
||||
// repository transaction.
|
||||
func (s *Service) QueueMessageWithMetadataForSessionWithClientQueueID(
|
||||
ctx context.Context,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID, content, model, userID string,
|
||||
planMode bool,
|
||||
attachments []MessageAttachment,
|
||||
metadata map[string]interface{},
|
||||
claim *QueueAttachmentClaim,
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if clientQueueID == "" || len(clientQueueID) > MaxQueueAdmissionIDLength {
|
||||
return nil, false, errors.New("client queue id is invalid")
|
||||
}
|
||||
writer, ok := s.repo.(queueAdmissionRepository)
|
||||
if !ok {
|
||||
return nil, false, ErrQueueAdmissionUnavailable
|
||||
}
|
||||
candidate := &QueuedMessage{
|
||||
ID: clientQueueID, SessionID: identity.SessionID, TaskID: identity.TaskID,
|
||||
Content: content, Model: model, PlanMode: planMode,
|
||||
Attachments: append([]MessageAttachment(nil), attachments...),
|
||||
Metadata: copyMessageMetadata(metadata, 0), QueuedBy: userID,
|
||||
}
|
||||
var admitted *QueuedMessage
|
||||
var replay bool
|
||||
err := s.WithSessionAdmission(ctx, identity.SessionID, func(admittedCtx context.Context) error {
|
||||
for {
|
||||
policy := s.resolveAdmissionAutoMergePolicy(admittedCtx, &identity, identity.SessionID)
|
||||
var err error
|
||||
admitted, replay, err = writer.AdmitQueueMessage(
|
||||
admittedCtx, identity, clientQueueID, candidate, claim, s.MaxPerSession(), policy,
|
||||
)
|
||||
if errors.Is(err, ErrAutoMergePolicyChanged) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return admitted, replay, nil
|
||||
}
|
||||
|
||||
// LookupQueueAdmissionWithClientQueueID checks a caller-owned admission
|
||||
// receipt before validation that depends on mutable external references or
|
||||
// attachment ownership.
|
||||
func (s *Service) LookupQueueAdmissionWithClientQueueID(
|
||||
ctx context.Context,
|
||||
identity QueueSessionIdentity,
|
||||
clientQueueID, content, model, userID string,
|
||||
planMode bool,
|
||||
attachments []MessageAttachment,
|
||||
metadata map[string]interface{},
|
||||
) (*QueuedMessage, bool, error) {
|
||||
if clientQueueID == "" || len(clientQueueID) > MaxQueueAdmissionIDLength {
|
||||
return nil, false, errors.New("client queue id is invalid")
|
||||
}
|
||||
reader, ok := s.repo.(queueAdmissionRepository)
|
||||
if !ok {
|
||||
return nil, false, ErrQueueAdmissionUnavailable
|
||||
}
|
||||
candidate := &QueuedMessage{
|
||||
ID: clientQueueID, SessionID: identity.SessionID, TaskID: identity.TaskID,
|
||||
Content: content, Model: model, PlanMode: planMode,
|
||||
Attachments: append([]MessageAttachment(nil), attachments...),
|
||||
Metadata: copyMessageMetadata(metadata, 0), QueuedBy: userID,
|
||||
}
|
||||
var admitted *QueuedMessage
|
||||
var replay bool
|
||||
err := s.WithSessionAdmission(ctx, identity.SessionID, func(admittedCtx context.Context) error {
|
||||
var lookupErr error
|
||||
admitted, replay, lookupErr = reader.LookupQueueAdmission(admittedCtx, identity, clientQueueID, candidate)
|
||||
return lookupErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return admitted, replay, nil
|
||||
}
|
||||
|
||||
// QueueMessageWithMetadataForSessionWithClaim atomically claims staged
|
||||
// attachments when admission inserts a queue entry. An empty claim carries
|
||||
// inline-only attachments and may use the compatible full-queue fold path.
|
||||
|
||||
@@ -50,6 +50,12 @@ const MetadataCoalesceKey = "coalesce_key"
|
||||
// queued message.
|
||||
const MetadataEntityReferences = "entity_references"
|
||||
|
||||
// MetadataQueueAdmissionIDs carries the client admission IDs that contributed
|
||||
// to a queued message. Unlike plan-comment admission metadata, this key does
|
||||
// not affect ordinary queue merge eligibility and is retained in the user
|
||||
// transcript for bounded client reconciliation after dispatch.
|
||||
const MetadataQueueAdmissionIDs = "queue_admission_ids"
|
||||
|
||||
// MetadataStepHandoff carries a completion-handoff carry token's claimed text
|
||||
// for a queued workflow auto-start prompt, so a dispatch path that defers
|
||||
// delivery through the queue (rather than sending it directly) still appends
|
||||
|
||||
@@ -59,7 +59,7 @@ var catalog = []Descriptor{
|
||||
{ID: "auth-hostnames", OwnerPackage: "internal/auth/hostnames", RequiredTables: []string{"auth_hostname_cache"}, DependsOn: []string{"auth"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "organizations", OwnerPackage: "internal/org", RequiredTables: []string{"orgs"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "organization-units", OwnerPackage: "internal/orgunit", RequiredTables: []string{"org_units"}, DependsOn: []string{"organizations"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "message-queue", OwnerPackage: "internal/orchestrator/messagequeue", RequiredTables: []string{"queued_messages"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
|
||||
{ID: "message-queue", OwnerPackage: "internal/orchestrator/messagequeue", RequiredTables: []string{"queued_messages", "queue_admission_receipts"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
|
||||
{ID: "task-share", OwnerPackage: "internal/task/share", RequiredTables: []string{"task_shares"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "telemetry-contract", OwnerPackage: "internal/telemetrycontract", RequiredTables: []string{"telemetry_activations"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "delivery", OwnerPackage: "internal/delivery", RequiredTables: []string{"task_delivery_ledger"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package storeconformance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -57,6 +58,24 @@ type metaBehaviorRecord struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type queueAdmissionStore interface {
|
||||
AdmitQueueMessage(
|
||||
context.Context,
|
||||
messagequeue.QueueSessionIdentity,
|
||||
string,
|
||||
*messagequeue.QueuedMessage,
|
||||
*messagequeue.QueueAttachmentClaim,
|
||||
int,
|
||||
*messagequeue.AutoMergePolicy,
|
||||
) (*messagequeue.QueuedMessage, bool, error)
|
||||
LookupQueueAdmission(
|
||||
context.Context,
|
||||
messagequeue.QueueSessionIdentity,
|
||||
string,
|
||||
*messagequeue.QueuedMessage,
|
||||
) (*messagequeue.QueuedMessage, bool, error)
|
||||
}
|
||||
|
||||
//nolint:funlen // The catalog-to-owner mapping is deliberately explicit.
|
||||
func buildOwnerBehaviors() map[string]ownerBehavior {
|
||||
behaviors := map[string]ownerBehavior{
|
||||
@@ -1192,9 +1211,49 @@ func messageQueueAction() apiAction {
|
||||
return nil, err
|
||||
}
|
||||
msg := &messagequeue.QueuedMessage{ID: id, SessionID: sessionID, TaskID: taskID, Content: "initial", Model: "model", QueuedBy: "conformance", Metadata: map[string]interface{}{}}
|
||||
if err := store.(messagequeue.Repository).Insert(s.Context, msg, 10); err != nil {
|
||||
repository, ok := store.(messagequeue.Repository)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("message queue store %T does not implement Repository", store)
|
||||
}
|
||||
if err := repository.Insert(s.Context, msg, 10); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admissions, ok := store.(queueAdmissionStore)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("message queue store %T does not implement admission API", store)
|
||||
}
|
||||
var incarnationID string
|
||||
if err := s.DB.GetContext(s.Context, &incarnationID, s.DB.Rebind(`
|
||||
SELECT queue_incarnation_id FROM task_sessions WHERE id = ? AND task_id = ?
|
||||
`), sessionID, taskID); err != nil {
|
||||
return nil, fmt.Errorf("read conformance queue session identity: %w", err)
|
||||
}
|
||||
identity := messagequeue.QueueSessionIdentity{
|
||||
TaskID: taskID, SessionID: sessionID, SessionIncarnationID: incarnationID,
|
||||
}
|
||||
admissionID := id + "-admission"
|
||||
candidate := &messagequeue.QueuedMessage{
|
||||
ID: admissionID, SessionID: sessionID, TaskID: taskID,
|
||||
Content: "admitted", Model: "model", QueuedBy: "conformance",
|
||||
Metadata: map[string]interface{}{},
|
||||
}
|
||||
admitted, replay, err := admissions.AdmitQueueMessage(s.Context, identity, admissionID, candidate, nil, 10, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admit identified queue message: %w", err)
|
||||
}
|
||||
if replay || admitted == nil || admitted.ID != admissionID || admitted.Content != candidate.Content {
|
||||
return nil, fmt.Errorf("identified queue admission = %+v, replay=%t", admitted, replay)
|
||||
}
|
||||
replayed, replay, err := admissions.LookupQueueAdmission(s.Context, identity, admissionID, candidate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup identified queue admission: %w", err)
|
||||
}
|
||||
if !replay || replayed == nil || replayed.ID != admitted.ID || replayed.Content != admitted.Content {
|
||||
return nil, fmt.Errorf("identified queue admission replay = %+v, replay=%t", replayed, replay)
|
||||
}
|
||||
if err := repository.DeleteByID(s.Context, sessionID, admitted.ID); err != nil {
|
||||
return nil, fmt.Errorf("clean up identified queue admission: %w", err)
|
||||
}
|
||||
return &queueRecord{ID: msg.ID, SessionID: msg.SessionID, TaskID: msg.TaskID, Content: msg.Content, QueuedAt: msg.QueuedAt, PlanMode: msg.PlanMode}, nil
|
||||
}
|
||||
findQueueMessage := func(s testconformance.ScenarioContext, id string) (*messagequeue.QueuedMessage, error) {
|
||||
|
||||
@@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { planCommentRecovery } from "@/lib/plan-comment-recovery";
|
||||
import { QueueAdmissionError, QueueFullError } from "@/lib/api/domains/queue-api";
|
||||
|
||||
const toastMock = vi.fn();
|
||||
const handleSendMessageMock = vi.fn();
|
||||
const useKeyboardShortcutMock = vi.hoisted(() => vi.fn());
|
||||
const MESSAGE_NOT_SENT_TITLE = "Message not sent";
|
||||
let mockProceedStepName: string | null = null;
|
||||
let mockQueuePopulated = false;
|
||||
|
||||
@@ -439,7 +441,7 @@ describe("useSubmitHandler task plan comments", () => {
|
||||
|
||||
expect(handleSendMessageMock).not.toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Message not sent",
|
||||
title: MESSAGE_NOT_SENT_TITLE,
|
||||
description: "Saved plan comments are still being restored. Your message is kept.",
|
||||
variant: "error",
|
||||
});
|
||||
@@ -499,13 +501,47 @@ describe("useSubmitHandler deterministic failures", () => {
|
||||
});
|
||||
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Message not sent",
|
||||
title: MESSAGE_NOT_SENT_TITLE,
|
||||
description: message,
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSubmitHandler queue admission failures", () => {
|
||||
it("shows localized capacity feedback for a rejected queue admission", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
handleSendMessageMock.mockRejectedValueOnce(new QueueFullError(10, 10));
|
||||
const { result } = renderHook(() => useSubmitHandler(panelState()));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ message: "keep this draft" });
|
||||
});
|
||||
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: MESSAGE_NOT_SENT_TITLE,
|
||||
description: "The message queue is full. Wait for the next turn to drain.",
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows localized identity feedback without exposing the server diagnostic", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
handleSendMessageMock.mockRejectedValueOnce(new QueueAdmissionError("identity-conflict"));
|
||||
const { result } = renderHook(() => useSubmitHandler(panelState()));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit({ message: "keep this draft" });
|
||||
});
|
||||
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: MESSAGE_NOT_SENT_TITLE,
|
||||
description: "This draft was already submitted with different content.",
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSubmitHandler message comments", () => {
|
||||
it("keeps agent message comments pending when sending fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions";
|
||||
import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use-executor-environment-availability";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { isMessageSendError, MessageSendError } from "@/lib/chat/message-send-error";
|
||||
import { QueueAdmissionError, QueueFullError } from "@/lib/api/domains/queue-api";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { AgentMessageComment } from "@/lib/state/slices/comments";
|
||||
import type { ChatPanelState } from "./use-chat-panel-state";
|
||||
@@ -129,6 +130,28 @@ function pickInputPlaceholder(a: PlaceholderArgs): string {
|
||||
* send error from an ambiguous connection drop/timeout. */
|
||||
function showMessageSendToast(error: unknown, toast: ReturnType<typeof useToast>["toast"]) {
|
||||
console.error("Failed to send message:", error);
|
||||
if (error instanceof QueueFullError) {
|
||||
toast({
|
||||
title: t("task:messageNotSent"),
|
||||
description: t("task:queueAdmissionFull"),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error instanceof QueueAdmissionError) {
|
||||
const copy = {
|
||||
validation: t("task:queueAdmissionValidation"),
|
||||
"identity-conflict": t("task:queueAdmissionIdentityConflict"),
|
||||
"session-unavailable": t("task:queueAdmissionSessionUnavailable"),
|
||||
unavailable: t("task:queueAdmissionUnavailable"),
|
||||
}[error.code];
|
||||
toast({
|
||||
title: t("task:messageNotSent"),
|
||||
description: copy,
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isMessageSendError(error)) {
|
||||
toast({
|
||||
title: t("task:messageNotSent"),
|
||||
|
||||
@@ -16,6 +16,15 @@ type MessageAddResponseDropController = {
|
||||
droppedCount: () => number;
|
||||
};
|
||||
|
||||
export type QueueAdmissionDropController = {
|
||||
dropNextQueueAddRequest: () => void;
|
||||
dropNextQueueAddResponse: (count?: number) => void;
|
||||
dropQueueAdmissionReconciliation: () => void;
|
||||
queueAddRequestCount: () => number;
|
||||
droppedRequestCount: () => number;
|
||||
droppedResponseCount: () => number;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -222,3 +231,136 @@ export async function routeMainWebSocketWithMessageAddResponseDrop(
|
||||
droppedCount: () => dropped.value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects one pre-server request loss or one post-admission response loss for
|
||||
* `message.queue.add`. Every other gateway frame continues through the proxy.
|
||||
*/
|
||||
type QueueAdmissionProxyState = {
|
||||
responseRequestIDs: Set<string>;
|
||||
dropRequest: { value: boolean };
|
||||
dropResponseCount: { value: number };
|
||||
dropReconciliation: { value: boolean };
|
||||
queueAddRequests: { value: number };
|
||||
droppedRequests: { value: number };
|
||||
droppedResponses: { value: number };
|
||||
};
|
||||
|
||||
function parseQueueAdmissionFrame(part: string): Record<string, unknown> | undefined {
|
||||
if (!part.trim()) return undefined;
|
||||
try {
|
||||
return asRecord(JSON.parse(part.trim())) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function filterQueueAdmissionFrames(
|
||||
message: string,
|
||||
shouldDrop: (frame: Record<string, unknown>) => boolean,
|
||||
): string {
|
||||
return message
|
||||
.split("\n")
|
||||
.filter((part) => {
|
||||
const frame = parseQueueAdmissionFrame(part);
|
||||
return frame === undefined || !shouldDrop(frame);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function inspectQueueAdmissionRequest(
|
||||
frame: Record<string, unknown>,
|
||||
state: QueueAdmissionProxyState,
|
||||
): boolean {
|
||||
if (frame.type !== "request" || frame.action !== "message.queue.add") return false;
|
||||
state.queueAddRequests.value += 1;
|
||||
if (state.dropRequest.value) {
|
||||
state.dropRequest.value = false;
|
||||
state.droppedRequests.value += 1;
|
||||
return true;
|
||||
}
|
||||
if (state.dropResponseCount.value > 0 && typeof frame.id === "string") {
|
||||
state.responseRequestIDs.add(frame.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inspectQueueAdmissionResponse(
|
||||
frame: Record<string, unknown>,
|
||||
state: QueueAdmissionProxyState,
|
||||
): boolean {
|
||||
const isReconciliationResponse =
|
||||
state.dropReconciliation.value &&
|
||||
frame.type === "response" &&
|
||||
(frame.action === "message.queue.get" || frame.action === "message.list");
|
||||
if (isReconciliationResponse) return true;
|
||||
|
||||
const isAdmissionResponse =
|
||||
frame.type === "response" &&
|
||||
frame.action === "message.queue.add" &&
|
||||
typeof frame.id === "string" &&
|
||||
state.responseRequestIDs.has(frame.id) &&
|
||||
state.dropResponseCount.value > 0;
|
||||
if (!isAdmissionResponse) return false;
|
||||
|
||||
state.responseRequestIDs.delete(frame.id as string);
|
||||
state.dropResponseCount.value -= 1;
|
||||
state.droppedResponses.value += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function routeMainWebSocketWithQueueAdmissionDrops(
|
||||
page: Page,
|
||||
): Promise<QueueAdmissionDropController> {
|
||||
const state: QueueAdmissionProxyState = {
|
||||
responseRequestIDs: new Set<string>(),
|
||||
dropRequest: { value: false },
|
||||
dropResponseCount: { value: 0 },
|
||||
dropReconciliation: { value: false },
|
||||
queueAddRequests: { value: 0 },
|
||||
droppedRequests: { value: 0 },
|
||||
droppedResponses: { value: 0 },
|
||||
};
|
||||
|
||||
await page.routeWebSocket(/\/ws$/, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
const filtered = filterQueueAdmissionFrames(message, (frame) =>
|
||||
inspectQueueAdmissionRequest(frame, state),
|
||||
);
|
||||
if (filtered.trim()) server.send(filtered);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
const filtered = filterQueueAdmissionFrames(message, (frame) =>
|
||||
inspectQueueAdmissionResponse(frame, state),
|
||||
);
|
||||
if (filtered.trim()) ws.send(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
dropNextQueueAddRequest: () => {
|
||||
state.dropRequest.value = true;
|
||||
},
|
||||
dropNextQueueAddResponse: (count = 1) => {
|
||||
state.responseRequestIDs.clear();
|
||||
state.dropResponseCount.value = Math.max(1, count);
|
||||
state.dropReconciliation.value = false;
|
||||
state.droppedResponses.value = 0;
|
||||
},
|
||||
dropQueueAdmissionReconciliation: () => {
|
||||
state.dropReconciliation.value = true;
|
||||
},
|
||||
queueAddRequestCount: () => state.queueAddRequests.value,
|
||||
droppedRequestCount: () => state.droppedRequests.value,
|
||||
droppedResponseCount: () => state.droppedResponses.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { registerSeparateQueueRows } from "../../helpers/message-queue-settings";
|
||||
import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { routeMainWebSocketWithQueueAdmissionDrops } from "../../helpers/ws-drop";
|
||||
import { openQuickChatWithAgent, sendQuickChatMessage } from "./quick-chat-helpers";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
registerSeparateQueueRows(test);
|
||||
|
||||
async function expectTouchTarget(locator: ReturnType<SessionPage["submitButton"]>): Promise<void> {
|
||||
await expect(locator).toBeVisible();
|
||||
const box = await locator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
async function seedMobileBusyTask(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
): Promise<{ session: SessionPage; taskId: string; sessionId: string }> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Mobile queue admission request recovery",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
await session.sendMessageViaButton("/sleep 30");
|
||||
await session.agentStatus().waitFor({ state: "visible", timeout: 20_000 });
|
||||
await waitForActiveSessionForegroundActivity(testPage, "generating");
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
return { session, taskId: task.id, sessionId: task.session_id };
|
||||
}
|
||||
|
||||
test.describe("mobile queue admission reliability", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("retries a pre-server lost Task admission once and clears the draft", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const drops = await routeMainWebSocketWithQueueAdmissionDrops(testPage);
|
||||
const { session, taskId, sessionId } = await seedMobileBusyTask(testPage, apiClient, seedData);
|
||||
await waitForComposerQueueMode(session.activeChat());
|
||||
|
||||
const prompt = "recover the lost mobile queue request";
|
||||
drops.dropNextQueueAddRequest();
|
||||
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible");
|
||||
await typeWhileBusy(testPage, editor, prompt);
|
||||
await expectTouchTarget(session.submitButton());
|
||||
await session.tapSubmitWhenReady();
|
||||
|
||||
const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
|
||||
await expect(editor).toHaveText("", { timeout: 45_000 });
|
||||
await expect(session.activeChat().getByTestId("queue-chip")).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getQueueStatus(identity)).count, { timeout: 15_000 })
|
||||
.toBe(1);
|
||||
await expect.poll(() => drops.queueAddRequestCount()).toBe(2);
|
||||
await expect.poll(() => drops.droppedRequestCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("reconciles a lost accepted Quick Chat response through a touch submit", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const drops = await routeMainWebSocketWithQueueAdmissionDrops(testPage);
|
||||
const dialog = await openQuickChatWithAgent(testPage);
|
||||
await sendQuickChatMessage(dialog, testPage, "/sleep 30");
|
||||
await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible(
|
||||
{
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
await waitForComposerQueueMode(dialog);
|
||||
|
||||
const prompt = "recover the mobile Quick Chat admission";
|
||||
drops.dropNextQueueAddResponse();
|
||||
const editor = dialog.locator(".tiptap.ProseMirror:visible");
|
||||
await typeWhileBusy(testPage, editor, prompt);
|
||||
const submit = dialog.getByTestId("submit-message-button");
|
||||
await expectTouchTarget(submit);
|
||||
await submit.tap();
|
||||
|
||||
await expect(editor).toHaveText("", { timeout: 30_000 });
|
||||
await expect(dialog.getByTestId("queue-chip")).toBeVisible({ timeout: 15_000 });
|
||||
await expect.poll(() => drops.queueAddRequestCount()).toBe(1);
|
||||
await expect.poll(() => drops.droppedResponseCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { seedRunningGeneratingSession } from "../../helpers/generating-session";
|
||||
import {
|
||||
registerSeparateQueueRows,
|
||||
requestMessageQueueSettings,
|
||||
} from "../../helpers/message-queue-settings";
|
||||
import { routeSessionEntryRecovery } from "../../helpers/session-entry-recovery";
|
||||
import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy";
|
||||
import { routeMainWebSocketWithQueueAdmissionDrops } from "../../helpers/ws-drop";
|
||||
import { openQuickChatWithAgent, sendQuickChatMessage } from "./quick-chat-helpers";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
registerSeparateQueueRows(test);
|
||||
|
||||
const QUEUE_CAPACITY = 10;
|
||||
const ATTACHMENT_NAME = "queued-admission-evidence.txt";
|
||||
|
||||
async function fillQueue(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
count = QUEUE_CAPACITY,
|
||||
): Promise<Awaited<ReturnType<ApiClient["getQueueSessionIdentity"]>>> {
|
||||
const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await apiClient.queueMessage(identity, `Preloaded queue item ${index + 1}`);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getQueueStatus(identity)).count, {
|
||||
timeout: 15_000,
|
||||
message: "the queue should contain the preloaded entries",
|
||||
})
|
||||
.toBe(count);
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function queueFromTaskComposer(
|
||||
page: Parameters<typeof typeWhileBusy>[0],
|
||||
session: SessionPage,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const editor = session.activeChat().locator(".tiptap.ProseMirror:visible");
|
||||
await typeWhileBusy(page, editor, content);
|
||||
await session.clickSubmitWhenReady();
|
||||
}
|
||||
|
||||
test.describe("queue admission reliability", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("clears an attached Task draft after a six second admission response", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const recovery = await routeSessionEntryRecovery(testPage);
|
||||
const { session, taskId, sessionId } = await seedRunningGeneratingSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Queue admission attachment recovery",
|
||||
{ sleepSeconds: 30 },
|
||||
);
|
||||
|
||||
fs.mkdirSync(testInfo.outputDir, { recursive: true });
|
||||
const attachmentPath = path.join(testInfo.outputDir, ATTACHMENT_NAME);
|
||||
fs.writeFileSync(attachmentPath, "queue admission attachment bytes");
|
||||
await session.activeChat().locator('input[type="file"]').setInputFiles(attachmentPath);
|
||||
await expect(testPage.getByText(ATTACHMENT_NAME, { exact: true })).toBeVisible();
|
||||
|
||||
recovery.delayNextResponses(
|
||||
"message.queue.add",
|
||||
1,
|
||||
6_000,
|
||||
"six second queue admission response",
|
||||
);
|
||||
await queueFromTaskComposer(testPage, session, "queue the attached follow-up");
|
||||
|
||||
const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId);
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getQueueStatus(identity)).count, { timeout: 20_000 })
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(() => recovery.delayedResponseCount("message.queue.add"), { timeout: 20_000 })
|
||||
.toBe(1);
|
||||
|
||||
await expect(session.activeChat().locator(".tiptap.ProseMirror:visible")).toHaveText("", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await session.activeChat().getByTestId("queue-chip").click();
|
||||
const row = session.activeChat().getByTestId("queue-entry").first();
|
||||
await expect(row).toContainText("queue the attached follow-up");
|
||||
await expect(row).toContainText(ATTACHMENT_NAME);
|
||||
});
|
||||
|
||||
test("reconciles a lost accepted Quick Chat response without duplicating it", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const drops = await routeMainWebSocketWithQueueAdmissionDrops(testPage);
|
||||
const dialog = await openQuickChatWithAgent(testPage);
|
||||
await sendQuickChatMessage(dialog, testPage, "/sleep 30");
|
||||
await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible(
|
||||
{
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
await waitForComposerQueueMode(dialog);
|
||||
|
||||
const editor = dialog.locator(".tiptap.ProseMirror:visible");
|
||||
const prompt = "recover the accepted Quick Chat admission";
|
||||
drops.dropNextQueueAddResponse();
|
||||
await typeWhileBusy(testPage, editor, prompt);
|
||||
await dialog.getByTestId("submit-message-button").click();
|
||||
|
||||
await expect(editor).toHaveText("", { timeout: 30_000 });
|
||||
await expect(dialog.getByTestId("queue-chip")).toBeVisible({ timeout: 15_000 });
|
||||
await expect.poll(() => drops.queueAddRequestCount()).toBe(1);
|
||||
await expect.poll(() => drops.droppedResponseCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps the Task draft when admission and reconciliation remain uncertain", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const drops = await routeMainWebSocketWithQueueAdmissionDrops(testPage);
|
||||
const { session } = await seedRunningGeneratingSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Queue admission final uncertainty",
|
||||
{ sleepSeconds: 60 },
|
||||
);
|
||||
await waitForComposerQueueMode(session.activeChat());
|
||||
|
||||
const prompt = "keep this prompt after final uncertainty";
|
||||
drops.dropNextQueueAddResponse(2);
|
||||
drops.dropQueueAdmissionReconciliation();
|
||||
await queueFromTaskComposer(testPage, session, prompt);
|
||||
|
||||
const toast = testPage
|
||||
.getByTestId("toast-message")
|
||||
.filter({ hasText: "Message send status unknown" });
|
||||
await expect(toast).toContainText("The connection dropped or timed out", { timeout: 60_000 });
|
||||
await expect(session.activeChat().locator(".tiptap.ProseMirror:visible")).toHaveText(prompt);
|
||||
await expect.poll(() => drops.queueAddRequestCount()).toBe(2);
|
||||
await expect.poll(() => drops.droppedResponseCount()).toBe(2);
|
||||
});
|
||||
|
||||
test("rejects a full queue and then folds the retained draft when Auto-merge is enabled", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { session, taskId, sessionId } = await seedRunningGeneratingSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Queue admission Auto-merge capacity",
|
||||
{ sleepSeconds: 60 },
|
||||
);
|
||||
const identity = await fillQueue(apiClient, taskId, sessionId);
|
||||
await waitForComposerQueueMode(session.activeChat());
|
||||
|
||||
const prompt = "fold this retained full queue draft";
|
||||
await queueFromTaskComposer(testPage, session, prompt);
|
||||
const fullToast = testPage.getByTestId("toast-message").filter({ hasText: "Message not sent" });
|
||||
await expect(fullToast).toContainText("The message queue is full", { timeout: 15_000 });
|
||||
await expect(session.activeChat().locator(".tiptap.ProseMirror:visible")).toHaveText(prompt);
|
||||
|
||||
await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true });
|
||||
await session.clickSubmitWhenReady();
|
||||
await expect(session.activeChat().locator(".tiptap.ProseMirror:visible")).toHaveText("");
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getQueueStatus(identity)).count, { timeout: 20_000 })
|
||||
.toBe(QUEUE_CAPACITY);
|
||||
|
||||
await session.activeChat().getByTestId("queue-chip").click();
|
||||
await expect(session.activeChat().getByTestId("queue-entry").last()).toContainText(prompt);
|
||||
});
|
||||
});
|
||||
@@ -509,6 +509,21 @@ describe("useRunComment — plan routing", () => {
|
||||
expect(mockMarkCommentsSent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an accepted queued plan comment successful when comment refresh fails", async () => {
|
||||
const state = makeStoreState("WAITING_FOR_INPUT");
|
||||
(state.taskSessions.items["primary-session"] as { state: string }).state = "RUNNING";
|
||||
(state.taskSessionsByTask.itemsByTaskId["task-1"][0] as { state: string }).state = "RUNNING";
|
||||
mockStoreState = state;
|
||||
mockGetTaskPlanComments.mockRejectedValueOnce(new Error("plan comment refresh failed"));
|
||||
const { result } = renderCommentHook();
|
||||
|
||||
await expect(result.current.runComment(makePlanComment())).resolves.toEqual({ queued: true });
|
||||
expect(mockQueueMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ client_queue_id: expect.any(String) }),
|
||||
);
|
||||
expect(mockSetTaskPlanComments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues a steer-capable primary when earlier prompts are already queued", async () => {
|
||||
const state = makeStoreState("WAITING_FOR_INPUT");
|
||||
Object.assign(state.taskSessions.items["primary-session"], {
|
||||
|
||||
@@ -204,6 +204,34 @@ describe("useMessageHandler queued plan comments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMessageHandler ordinary queue admission", () => {
|
||||
it("passes a stable admission ID for ordinary queued messages", async () => {
|
||||
selectedSession("WAITING_FOR_INPUT");
|
||||
const { result } = renderHook(() =>
|
||||
useMessageHandler({
|
||||
resolvedSessionId: SESSION_ID,
|
||||
taskId: TASK_ID,
|
||||
sessionModel: null,
|
||||
activeModel: null,
|
||||
hasPendingClarification: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSendMessage({ message: "Keep this queued" });
|
||||
});
|
||||
|
||||
expect(queueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: TASK_ID,
|
||||
content: "Keep this queued",
|
||||
clientQueueId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(queueMock.mock.calls[0]?.[0]?.planCommentRefs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMessageHandler plan comment retries", () => {
|
||||
it("reuses an unresolved queue admission ID on manual retry", async () => {
|
||||
selectedSession("RUNNING", "generating");
|
||||
|
||||
@@ -530,6 +530,7 @@ describe("useMessageHandler input routing", () => {
|
||||
planMode: false,
|
||||
attachments: undefined,
|
||||
entityReferences: undefined,
|
||||
clientQueueId: expect.any(String),
|
||||
});
|
||||
expect(getWebSocketClientMock().request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -403,9 +403,8 @@ async function deliverComposedMessage({
|
||||
planMode: planModeEnabled,
|
||||
attachments: buildQueueAttachments(payload.attachments),
|
||||
entityReferences: payload.entityReferences,
|
||||
...(planCommentRefs.length > 0
|
||||
? { clientQueueId: clientAdmissionId, planCommentRefs }
|
||||
: {}),
|
||||
clientQueueId: clientAdmissionId,
|
||||
...(planCommentRefs.length > 0 ? { planCommentRefs } : {}),
|
||||
...(contextFilesMeta ? { contextFilesMeta } : {}),
|
||||
});
|
||||
if (!accepted) {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketRequestError } from "@/lib/ws/request-error";
|
||||
|
||||
const getWebSocketClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/ws/connection", () => ({
|
||||
getWebSocketClient: getWebSocketClientMock,
|
||||
}));
|
||||
|
||||
import { QueueAdmissionError, QueueFullError, queueMessage } from "./queue-api";
|
||||
|
||||
const baseParams = {
|
||||
session_id: "session-1",
|
||||
session_incarnation_id: "incarnation-1",
|
||||
task_id: "task-1",
|
||||
content: "queued prompt",
|
||||
client_queue_id: "client-queue-1",
|
||||
};
|
||||
const QUEUE_ADD_ACTION = "message.queue.add";
|
||||
const QUEUE_GET_ACTION = "message.queue.get";
|
||||
const MESSAGE_LIST_ACTION = "message.list";
|
||||
const ATTACHMENT_ID = "file-1";
|
||||
const QUEUE_TIMEOUT_ERROR = "WebSocket request timed out";
|
||||
|
||||
beforeEach(() => {
|
||||
getWebSocketClientMock.mockReset();
|
||||
});
|
||||
|
||||
describe("identified queue admission", () => {
|
||||
it("forwards an ordinary admission with the ten second budget", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ id: baseParams.client_queue_id });
|
||||
getWebSocketClientMock.mockReturnValue({ request });
|
||||
|
||||
await queueMessage(baseParams);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(QUEUE_ADD_ACTION, baseParams, 10000);
|
||||
});
|
||||
|
||||
it("uses the thirty second budget for attachment admission", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ id: baseParams.client_queue_id });
|
||||
getWebSocketClientMock.mockReturnValue({ request });
|
||||
const params = {
|
||||
...baseParams,
|
||||
attachments: [{ type: "resource", mime_type: "text/plain", attachment_id: ATTACHMENT_ID }],
|
||||
};
|
||||
|
||||
await queueMessage(params);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(QUEUE_ADD_ACTION, params, 30000);
|
||||
});
|
||||
|
||||
it("retries an uncertain ordinary admission once with the same budget and payload", async () => {
|
||||
let adds = 0;
|
||||
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
||||
if (action === QUEUE_ADD_ACTION) {
|
||||
adds++;
|
||||
if (adds === 1) throw new Error(QUEUE_TIMEOUT_ERROR);
|
||||
return { id: baseParams.client_queue_id };
|
||||
}
|
||||
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
||||
if (action === MESSAGE_LIST_ACTION) return { messages: [], has_more: false };
|
||||
return undefined;
|
||||
});
|
||||
getWebSocketClientMock.mockReturnValue({
|
||||
request,
|
||||
getStatus: () => "connected",
|
||||
});
|
||||
|
||||
await expect(queueMessage(baseParams)).resolves.toEqual({ id: baseParams.client_queue_id });
|
||||
|
||||
const admissions = request.mock.calls.filter(([action]) => action === QUEUE_ADD_ACTION);
|
||||
expect(admissions).toEqual([
|
||||
[QUEUE_ADD_ACTION, baseParams, 10000],
|
||||
[QUEUE_ADD_ACTION, baseParams, 10000],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the attachment budget for an uncertain retry", async () => {
|
||||
const params = {
|
||||
...baseParams,
|
||||
attachments: [{ type: "resource", mime_type: "text/plain", attachment_id: ATTACHMENT_ID }],
|
||||
};
|
||||
let adds = 0;
|
||||
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
||||
if (action === QUEUE_ADD_ACTION) {
|
||||
adds++;
|
||||
if (adds === 1) throw new Error(QUEUE_TIMEOUT_ERROR);
|
||||
return { id: baseParams.client_queue_id };
|
||||
}
|
||||
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
||||
if (action === MESSAGE_LIST_ACTION) return { messages: [], has_more: false };
|
||||
return undefined;
|
||||
});
|
||||
getWebSocketClientMock.mockReturnValue({
|
||||
request,
|
||||
getStatus: () => "connected",
|
||||
});
|
||||
|
||||
await expect(queueMessage(params)).resolves.toEqual({ id: baseParams.client_queue_id });
|
||||
|
||||
const admissions = request.mock.calls.filter(([action]) => action === QUEUE_ADD_ACTION);
|
||||
expect(admissions[0]?.[2]).toBe(30000);
|
||||
expect(admissions[1]?.[2]).toBe(30000);
|
||||
});
|
||||
|
||||
it("does not scan older transcript pages for an ordinary admission", async () => {
|
||||
let adds = 0;
|
||||
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
||||
if (action === QUEUE_ADD_ACTION) {
|
||||
adds++;
|
||||
if (adds === 1) throw new Error(QUEUE_TIMEOUT_ERROR);
|
||||
return { id: baseParams.client_queue_id };
|
||||
}
|
||||
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
||||
if (action === MESSAGE_LIST_ACTION) {
|
||||
return { messages: [], has_more: true, cursor: "older-page" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
getWebSocketClientMock.mockReturnValue({ request, getStatus: () => "connected" });
|
||||
|
||||
await queueMessage(baseParams);
|
||||
|
||||
expect(request).not.toHaveBeenCalledWith(
|
||||
MESSAGE_LIST_ACTION,
|
||||
expect.objectContaining({ before: "older-page" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-dispatch queue admission", () => {
|
||||
it("reconciles an accepted admission from ordinary transcript provenance", async () => {
|
||||
let admissions = 0;
|
||||
let transcriptReads = 0;
|
||||
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
||||
if (action === QUEUE_ADD_ACTION) {
|
||||
admissions++;
|
||||
throw new Error(QUEUE_TIMEOUT_ERROR);
|
||||
}
|
||||
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
||||
if (action === MESSAGE_LIST_ACTION) {
|
||||
transcriptReads++;
|
||||
if (transcriptReads < 2) return { messages: [], has_more: false };
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
id: "transcript-1",
|
||||
session_id: baseParams.session_id,
|
||||
task_id: baseParams.task_id,
|
||||
author_type: "user",
|
||||
content: baseParams.content,
|
||||
type: "message",
|
||||
created_at: "2026-09-14T12:00:00Z",
|
||||
metadata: { queue_admission_ids: [baseParams.client_queue_id] },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
getWebSocketClientMock.mockReturnValue({ request, getStatus: () => "connected" });
|
||||
|
||||
await expect(queueMessage(baseParams)).resolves.toMatchObject({
|
||||
id: baseParams.client_queue_id,
|
||||
content: baseParams.content,
|
||||
queued_at: "2026-09-14T12:00:00Z",
|
||||
});
|
||||
expect(admissions).toBe(2);
|
||||
expect(transcriptReads).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identified queue admission errors", () => {
|
||||
it("maps deterministic queue rejection codes to typed errors", async () => {
|
||||
const cases = [
|
||||
["queue_full", QueueFullError],
|
||||
["queue_id_conflict", QueueAdmissionError],
|
||||
["queue_session_unavailable", QueueAdmissionError],
|
||||
["queue_admission_unavailable", QueueAdmissionError],
|
||||
["VALIDATION_ERROR", QueueAdmissionError],
|
||||
["NOT_FOUND", QueueAdmissionError],
|
||||
] as const;
|
||||
for (const [code, errorType] of cases) {
|
||||
const request = vi.fn().mockRejectedValue(new WebSocketRequestError("rejected", code));
|
||||
getWebSocketClientMock.mockReturnValue({ request });
|
||||
|
||||
await expect(queueMessage(baseParams)).rejects.toBeInstanceOf(errorType);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry an explicit rejection", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new WebSocketRequestError("invalid queue request", "VALIDATION_ERROR"));
|
||||
getWebSocketClientMock.mockReturnValue({ request });
|
||||
|
||||
await expect(queueMessage(baseParams)).rejects.toMatchObject({
|
||||
name: "QueueAdmissionError",
|
||||
code: "validation",
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -34,16 +34,20 @@ describe("task plan queue admission", () => {
|
||||
require_primary_session: true,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledWith(QUEUE_ADD_ACTION, {
|
||||
session_id: PRIMARY_SESSION_ID,
|
||||
session_incarnation_id: INCARNATION_ID,
|
||||
task_id: "task-1",
|
||||
client_queue_id: CLIENT_QUEUE_ID,
|
||||
content: "",
|
||||
plan_mode: true,
|
||||
plan_comment_refs: [{ id: "comment-1", version: 2 }],
|
||||
require_primary_session: true,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
QUEUE_ADD_ACTION,
|
||||
{
|
||||
session_id: PRIMARY_SESSION_ID,
|
||||
session_incarnation_id: INCARNATION_ID,
|
||||
task_id: "task-1",
|
||||
client_queue_id: CLIENT_QUEUE_ID,
|
||||
content: "",
|
||||
plan_mode: true,
|
||||
plan_comment_refs: [{ id: "comment-1", version: 2 }],
|
||||
require_primary_session: true,
|
||||
},
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
it("reconciles a timed-out comment queue admission from queue state", async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string -- Wire action and fixture IDs are intentionally repeated for readability. */
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import { planCommentAdmissionConflict } from "@/lib/plan-comment-refs";
|
||||
import { WebSocketRequestError } from "@/lib/ws/request-error";
|
||||
|
||||
const getWebSocketClientMock = vi.hoisted(() => vi.fn());
|
||||
const QUEUE_ADD_ACTION = "message.queue.add";
|
||||
@@ -131,7 +133,83 @@ describe("rethrowQueueError", () => {
|
||||
}
|
||||
expect(caught).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured plan comment conflicts", () => {
|
||||
it("preserves structured plan comment conflicts and their snapshots", () => {
|
||||
const details = {
|
||||
snapshot: {
|
||||
task_id: "task-1",
|
||||
plan_id: "plan-1",
|
||||
revision: 3,
|
||||
comments: [],
|
||||
},
|
||||
};
|
||||
const original = new WebSocketRequestError(
|
||||
"Plan comments changed",
|
||||
"plan_comments_changed",
|
||||
details,
|
||||
);
|
||||
let caught: unknown;
|
||||
try {
|
||||
rethrowQueueError(original);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBe(original);
|
||||
expect((caught as WebSocketRequestError).code).toBe("plan_comments_changed");
|
||||
expect((caught as WebSocketRequestError).details).toEqual(details);
|
||||
expect(planCommentAdmissionConflict(caught)).toMatchObject({
|
||||
code: "plan_comments_changed",
|
||||
snapshot: details.snapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves structured primary-session conflicts and their details", () => {
|
||||
const details = {
|
||||
primary_session_id: "session-2",
|
||||
primary_session_state: "RUNNING",
|
||||
};
|
||||
const original = new WebSocketRequestError(
|
||||
"Primary session changed",
|
||||
"primary_session_changed",
|
||||
details,
|
||||
);
|
||||
let caught: unknown;
|
||||
try {
|
||||
rethrowQueueError(original);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBe(original);
|
||||
expect((caught as WebSocketRequestError).code).toBe("primary_session_changed");
|
||||
expect((caught as WebSocketRequestError).details).toEqual(details);
|
||||
expect(planCommentAdmissionConflict(caught)).toMatchObject({
|
||||
code: "primary_session_changed",
|
||||
primarySessionId: "session-2",
|
||||
primarySessionState: "RUNNING",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves admission-only validation errors for non-admission operations", () => {
|
||||
const details = { field: "content" };
|
||||
const original = new WebSocketRequestError("Invalid queue edit", "VALIDATION_ERROR", details);
|
||||
let caught: unknown;
|
||||
try {
|
||||
rethrowQueueError(original);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBe(original);
|
||||
expect((caught as WebSocketRequestError).code).toBe("VALIDATION_ERROR");
|
||||
expect((caught as WebSocketRequestError).details).toEqual(details);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rethrowQueueError values", () => {
|
||||
it("wraps non-Error non-WSError values in an Error so callers can rely on stack traces", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { QueueStatus, QueuedMessage } from "@/lib/state/slices/session/type
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import type { Message, TaskPlanCommentRef } from "@/lib/types/http";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { WebSocketRequestError } from "@/lib/ws/request-error";
|
||||
// i18n-exempt: precondition diagnostic for a programmer error; callers branch
|
||||
// on the error type, never render this message.
|
||||
const WS_CLIENT_UNAVAILABLE = "WebSocket client not available";
|
||||
@@ -20,6 +21,23 @@ export class QueueFullError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type QueueAdmissionErrorCode =
|
||||
| "validation"
|
||||
| "identity-conflict"
|
||||
| "session-unavailable"
|
||||
| "unavailable";
|
||||
|
||||
/** Error returned when an identified queue admission is rejected deterministically. */
|
||||
export class QueueAdmissionError extends Error {
|
||||
readonly code: QueueAdmissionErrorCode;
|
||||
|
||||
constructor(code: QueueAdmissionErrorCode) {
|
||||
super(code);
|
||||
this.name = "QueueAdmissionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Error thrown when the targeted entry was already drained or does not exist. */
|
||||
export class QueueEntryNotFoundError extends Error {
|
||||
readonly code = "entry_not_found";
|
||||
@@ -103,47 +121,67 @@ type WSError = {
|
||||
details?: { queue_size?: number; max?: number; [k: string]: unknown };
|
||||
};
|
||||
|
||||
const QUEUE_ADMISSION_ERROR_CODES: Readonly<Record<string, QueueAdmissionErrorCode>> = {
|
||||
queue_id_conflict: "identity-conflict",
|
||||
queue_session_unavailable: "session-unavailable",
|
||||
queue_admission_unavailable: "unavailable",
|
||||
UNAVAILABLE: "unavailable",
|
||||
VALIDATION_ERROR: "validation",
|
||||
NOT_FOUND: "session-unavailable",
|
||||
};
|
||||
|
||||
type QueueErrorContext = "admission" | "operation";
|
||||
|
||||
function asWSError(err: unknown): WSError | undefined {
|
||||
// Some WS error payloads omit `code` and only carry `message`/`details`;
|
||||
// narrowing on `code` alone would drop those and stringify the whole object
|
||||
// as the eventual Error message ("[object Object]"). Real Error instances
|
||||
// are skipped so they pass through unchanged in `rethrowQueueError`.
|
||||
if (typeof err !== "object" || err === null || err instanceof Error) {
|
||||
// pass through unchanged, except for structured request errors from the WS
|
||||
// client, which retain their wire code for queue-specific mapping.
|
||||
if (typeof err !== "object" || err === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
if (!(err instanceof WebSocketRequestError)) return undefined;
|
||||
return {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: err.details as WSError["details"],
|
||||
};
|
||||
}
|
||||
if ("code" in err || "message" in err || "details" in err) {
|
||||
return err as WSError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function knownQueueError(wsErr: WSError): Error | undefined {
|
||||
switch (wsErr.code) {
|
||||
case "queue_full": {
|
||||
const size = typeof wsErr.details?.queue_size === "number" ? wsErr.details.queue_size : 0;
|
||||
const max = typeof wsErr.details?.max === "number" ? wsErr.details.max : 0;
|
||||
return new QueueFullError(size, max);
|
||||
}
|
||||
case "entry_not_found":
|
||||
return new QueueEntryNotFoundError();
|
||||
case "edit_conflict":
|
||||
case "queue_conflict":
|
||||
return new QueueEditConflictError();
|
||||
case "merge_reference_overflow":
|
||||
return new MergeReferenceOverflowError();
|
||||
default:
|
||||
if (wsErr.code && QUEUE_SEND_NOW_ERROR_CODES.has(wsErr.code as QueueSendNowErrorCode)) {
|
||||
return new QueueSendNowError(wsErr.code as QueueSendNowErrorCode, wsErr.message);
|
||||
}
|
||||
return undefined;
|
||||
function knownQueueError(wsErr: WSError, context: QueueErrorContext): Error | undefined {
|
||||
if (wsErr.code === "queue_full") {
|
||||
const size = typeof wsErr.details?.queue_size === "number" ? wsErr.details.queue_size : 0;
|
||||
const max = typeof wsErr.details?.max === "number" ? wsErr.details.max : 0;
|
||||
return new QueueFullError(size, max);
|
||||
}
|
||||
if (context === "admission") {
|
||||
const admissionCode = wsErr.code ? QUEUE_ADMISSION_ERROR_CODES[wsErr.code] : undefined;
|
||||
if (admissionCode) return new QueueAdmissionError(admissionCode);
|
||||
}
|
||||
if (wsErr.code === "entry_not_found") return new QueueEntryNotFoundError();
|
||||
if (wsErr.code === "edit_conflict" || wsErr.code === "queue_conflict") {
|
||||
return new QueueEditConflictError();
|
||||
}
|
||||
if (wsErr.code === "merge_reference_overflow") return new MergeReferenceOverflowError();
|
||||
if (wsErr.code && QUEUE_SEND_NOW_ERROR_CODES.has(wsErr.code as QueueSendNowErrorCode)) {
|
||||
return new QueueSendNowError(wsErr.code as QueueSendNowErrorCode, wsErr.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function rethrowQueueError(err: unknown): never {
|
||||
export function rethrowQueueError(err: unknown, context: QueueErrorContext = "operation"): never {
|
||||
const wsErr = asWSError(err);
|
||||
if (wsErr) {
|
||||
const known = knownQueueError(wsErr);
|
||||
const known = knownQueueError(wsErr, context);
|
||||
if (known) throw known;
|
||||
if (err instanceof WebSocketRequestError) throw err;
|
||||
if (wsErr.message) throw new Error(wsErr.message);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
@@ -189,6 +227,7 @@ function isUncertainQueueTransportError(error: unknown): boolean {
|
||||
|
||||
function hasQueueAdmissionMetadata(metadata: Message["metadata"], clientQueueId: string): boolean {
|
||||
if (metadata?.client_queue_id === clientQueueId) return true;
|
||||
if (metadata?.queue_admission_ids?.includes(clientQueueId)) return true;
|
||||
const sources = metadata?.send_now_sources;
|
||||
if (!Array.isArray(sources)) return false;
|
||||
return sources.some((source) => {
|
||||
@@ -205,6 +244,7 @@ function hasQueueAdmissionMetadata(metadata: Message["metadata"], clientQueueId:
|
||||
async function findAcceptedQueueAdmission(
|
||||
client: NonNullable<ReturnType<typeof getWebSocketClient>>,
|
||||
params: QueueMessageParams & { client_queue_id: string },
|
||||
scanOlderTranscriptPages: boolean,
|
||||
): Promise<QueuedMessage | undefined> {
|
||||
try {
|
||||
const status = await client.request<QueueStatus>("message.queue.get", {
|
||||
@@ -252,7 +292,9 @@ async function findAcceptedQueueAdmission(
|
||||
};
|
||||
}
|
||||
const cursor = response.cursor;
|
||||
if (!response.has_more || !cursor || seenCursors.has(cursor)) return undefined;
|
||||
if (!scanOlderTranscriptPages || !response.has_more || !cursor || seenCursors.has(cursor)) {
|
||||
return undefined;
|
||||
}
|
||||
seenCursors.add(cursor);
|
||||
before = cursor;
|
||||
} while (before);
|
||||
@@ -277,27 +319,45 @@ async function reconcileUncertainQueueAdmission(
|
||||
client: NonNullable<ReturnType<typeof getWebSocketClient>>,
|
||||
params: QueueMessageParams & { client_queue_id: string },
|
||||
originalError: unknown,
|
||||
request: () => Promise<QueuedMessage>,
|
||||
) {
|
||||
const accepted = await findAcceptedQueueAdmission(client, params);
|
||||
const scanOlderTranscriptPages = Boolean(params.plan_comment_refs?.length);
|
||||
const accepted = await findAcceptedQueueAdmission(client, params, scanOlderTranscriptPages);
|
||||
if (accepted) return accepted;
|
||||
if (!(await waitForQueueConnection(client))) throw originalError;
|
||||
try {
|
||||
return await client.request<QueuedMessage>("message.queue.add", params);
|
||||
return await request();
|
||||
} catch (retryError) {
|
||||
const acceptedAfterRetry = await findAcceptedQueueAdmission(client, params);
|
||||
const acceptedAfterRetry = await findAcceptedQueueAdmission(
|
||||
client,
|
||||
params,
|
||||
scanOlderTranscriptPages,
|
||||
);
|
||||
if (acceptedAfterRetry) return acceptedAfterRetry;
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
function requestQueueAdmission(
|
||||
client: NonNullable<ReturnType<typeof getWebSocketClient>>,
|
||||
params: QueueMessageParams,
|
||||
) {
|
||||
if (!params.client_queue_id) {
|
||||
return client.request<QueuedMessage>("message.queue.add", params);
|
||||
}
|
||||
const timeout = params.attachments?.length ? 30000 : 10000;
|
||||
return client.request<QueuedMessage>("message.queue.add", params, timeout);
|
||||
}
|
||||
|
||||
/** Append a new entry to the session's FIFO queue. Throws QueueFullError on overflow. */
|
||||
export async function queueMessage(params: QueueMessageParams): Promise<QueuedMessage> {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
throw new Error(WS_CLIENT_UNAVAILABLE);
|
||||
}
|
||||
const request = () => requestQueueAdmission(client, params);
|
||||
try {
|
||||
return await client.request<QueuedMessage>("message.queue.add", params);
|
||||
return await request();
|
||||
} catch (err) {
|
||||
if (params.client_queue_id && isUncertainQueueTransportError(err)) {
|
||||
try {
|
||||
@@ -305,12 +365,13 @@ export async function queueMessage(params: QueueMessageParams): Promise<QueuedMe
|
||||
client,
|
||||
params as QueueMessageParams & { client_queue_id: string },
|
||||
err,
|
||||
request,
|
||||
);
|
||||
} catch (reconcileError) {
|
||||
rethrowQueueError(reconcileError);
|
||||
rethrowQueueError(reconcileError, "admission");
|
||||
}
|
||||
}
|
||||
rethrowQueueError(err);
|
||||
rethrowQueueError(err, "admission");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ export type WalkthroughsState = {
|
||||
|
||||
export type QueuedMessageMetadata = Record<string, unknown> & {
|
||||
entity_references?: EntityReference[];
|
||||
queue_admission_ids?: string[];
|
||||
workflow_message?: boolean;
|
||||
workflow_auto_start?: boolean;
|
||||
workflow_step_id?: string;
|
||||
|
||||
@@ -974,6 +974,7 @@ export type MessageType =
|
||||
export type MessageMetadata = Record<string, unknown> & {
|
||||
entity_references?: EntityReference[];
|
||||
client_queue_id?: string;
|
||||
queue_admission_ids?: string[];
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
|
||||
@@ -1198,6 +1198,11 @@
|
||||
"questionCount_other": "{{count}} questions",
|
||||
"questionOfTotal": "Question {{index}} of {{total}}",
|
||||
"questionOfTotalAnswered": "Question {{index}} of {{total}} (answered)",
|
||||
"queueAdmissionFull": "The message queue is full. Wait for the next turn to drain.",
|
||||
"queueAdmissionIdentityConflict": "This draft was already submitted with different content.",
|
||||
"queueAdmissionSessionUnavailable": "The selected session is no longer available for input.",
|
||||
"queueAdmissionUnavailable": "The message queue is temporarily unavailable. Try again.",
|
||||
"queueAdmissionValidation": "The message content or attachments are invalid.",
|
||||
"queueMoreInstructions": "Queue more instructions...",
|
||||
"queued": "Queued",
|
||||
"quorumAwaiting": "Awaiting {{received}} of {{required}} {{role}} decisions",
|
||||
|
||||
@@ -1198,6 +1198,11 @@
|
||||
"questionCount_other": "{{count}} qũēśţĩōńś",
|
||||
"questionOfTotal": "Qũēśţĩōń {{index}} ōƒ {{total}}",
|
||||
"questionOfTotalAnswered": "Qũēśţĩōń {{index}} ōƒ {{total}} (àńśŵēŕēď)",
|
||||
"queueAdmissionFull": "Ţĥē ḿēśśàĝē qũēũē ĩś ƒũĺĺ. Ŵàĩţ ƒōŕ ţĥē ńēxţ ţũŕń ţō ďŕàĩń.",
|
||||
"queueAdmissionIdentityConflict": "Ţĥĩś ďŕàƒţ ŵàś àĺŕēàďŷ śũƀḿĩţţēď ŵĩţĥ ďĩƒƒēŕēńţ ćōńţēńţ.",
|
||||
"queueAdmissionSessionUnavailable": "Ţĥē śēĺēćţēď śēśśĩōń ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē ƒōŕ ĩńƥũţ.",
|
||||
"queueAdmissionUnavailable": "Ţĥē ḿēśśàĝē qũēũē ĩś ţēḿƥōŕàŕĩĺŷ ũńàvàĩĺàƀĺē. Ţŕŷ àĝàĩń.",
|
||||
"queueAdmissionValidation": "Ţĥē ḿēśśàĝē ćōńţēńţ ōŕ àţţàćĥḿēńţś àŕē ĩńvàĺĩď.",
|
||||
"queueMoreInstructions": "Qũēũē ḿōŕē ĩńśţŕũćţĩōńś...",
|
||||
"queued": "Qũēũēď",
|
||||
"quorumAwaiting": "Àŵàĩţĩńĝ {{received}} ōƒ {{required}} {{role}} ďēćĩśĩōńś",
|
||||
|
||||
@@ -1141,6 +1141,11 @@
|
||||
"questionCount_other": "{{count}} perguntas",
|
||||
"questionOfTotal": "Pergunta {{index}} de {{total}}",
|
||||
"questionOfTotalAnswered": "Pergunta {{index}} de {{total}} (respondida)",
|
||||
"queueAdmissionFull": "A fila de mensagens está cheia. Aguarde até à próxima vez para ser esvaziada.",
|
||||
"queueAdmissionIdentityConflict": "Este rascunho já foi enviado com conteúdo diferente.",
|
||||
"queueAdmissionSessionUnavailable": "A sessão selecionada já não está disponível para receber entradas.",
|
||||
"queueAdmissionUnavailable": "A fila de mensagens está temporariamente indisponível. Tente novamente.",
|
||||
"queueAdmissionValidation": "O conteúdo da mensagem ou os anexos são inválidos.",
|
||||
"queueMoreInstructions": "Colocar mais instruções em fila...",
|
||||
"queued": "Em fila",
|
||||
"quorumAwaiting": "A aguardar {{received}} de {{required}} decisões de {{role}}",
|
||||
|
||||
@@ -1140,6 +1140,11 @@
|
||||
"questionCount_other": "{{count}} 个问题",
|
||||
"questionOfTotal": "问题 {{index}} / {{total}}",
|
||||
"questionOfTotalAnswered": "问题 {{index}} / {{total}}(已回答)",
|
||||
"queueAdmissionFull": "消息队列已满。请等待下一轮处理。",
|
||||
"queueAdmissionIdentityConflict": "此草稿已使用不同内容提交。",
|
||||
"queueAdmissionSessionUnavailable": "所选会话已无法接收输入。",
|
||||
"queueAdmissionUnavailable": "消息队列暂时不可用。请重试。",
|
||||
"queueAdmissionValidation": "消息内容或附件无效。",
|
||||
"queueMoreInstructions": "排队更多指示…",
|
||||
"queued": "已排队",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定",
|
||||
|
||||
@@ -1140,6 +1140,11 @@
|
||||
"questionCount_other": "{{count}} 個問題",
|
||||
"questionOfTotal": "問題 {{index}} / {{total}}",
|
||||
"questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)",
|
||||
"queueAdmissionFull": "訊息佇列已滿。請等待下一輪處理。",
|
||||
"queueAdmissionIdentityConflict": "此草稿已使用不同內容提交。",
|
||||
"queueAdmissionSessionUnavailable": "所選工作階段已無法接收輸入。",
|
||||
"queueAdmissionUnavailable": "訊息佇列暫時不可用。請重試。",
|
||||
"queueAdmissionValidation": "訊息內容或附件無效。",
|
||||
"queueMoreInstructions": "排隊更多指示…",
|
||||
"queued": "已排隊",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
|
||||
|
||||
@@ -1140,6 +1140,11 @@
|
||||
"questionCount_other": "{{count}} 個問題",
|
||||
"questionOfTotal": "問題 {{index}} / {{total}}",
|
||||
"questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)",
|
||||
"queueAdmissionFull": "訊息佇列已滿。請等待下一輪處理。",
|
||||
"queueAdmissionIdentityConflict": "此草稿已使用不同內容提交。",
|
||||
"queueAdmissionSessionUnavailable": "所選工作階段已無法接收輸入。",
|
||||
"queueAdmissionUnavailable": "訊息佇列暫時無法使用。請重試。",
|
||||
"queueAdmissionValidation": "訊息內容或附件無效。",
|
||||
"queueMoreInstructions": "排隊更多指示…",
|
||||
"queued": "已排隊",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-2026-09-14-durable-queue-admission-receipts: Durable queue admission receipts
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-09-14
|
||||
**Area:** protocol
|
||||
|
||||
## Context
|
||||
|
||||
Ordinary queue submissions lack server replay protection. Queue rows can merge or disappear before a client receives their admission response.
|
||||
Plan-comment rows have special replay handling and intentionally avoid automatic merging. Applying that exception to every prompt changes existing merge behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
The fix stores an admission receipt atomically with each identified ordinary admission.
|
||||
The receipt binds the request fingerprint and accepted response to the task, session incarnation, and client queue ID.
|
||||
It survives queue operations until the owning task or session is deleted. Stale incarnation checks still reject old requests.
|
||||
|
||||
Receipt identity is separate from queue-row identity. Ordinary admission provenance is stored in a merge-ignored metadata key and unioned when rows fold, so retries can reconcile transcript messages after dispatch. Retries consult the receipt before repeating capacity checks or attachment claims.
|
||||
The client enables ordinary retries only after the server supplies this contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
Lost responses no longer require an existing queue row or transcript message to prevent duplicate admission.
|
||||
The repository needs additive schema initialization, database parity, and lifecycle cleanup coverage.
|
||||
Receipt storage grows with accepted submissions in retained sessions. Response snapshots must not copy inline attachment bytes.
|
||||
Task and session purge transactions remove the associated receipts with the queue state. The accepted implementation package applies this decision.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- A longer timeout reduces failures but does not settle lost responses.
|
||||
- Searching queue and transcript alone leaves gaps during dispatch and after removal or merging.
|
||||
- Reusing the plan-comment path disables ordinary automatic merging and requires nonexistent comment references.
|
||||
- An in-memory receipt loses replay protection after restart.
|
||||
|
||||
## Related design
|
||||
|
||||
- [Queue admission](../specs/tasks/system-design/queue-admission.md)
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
created: 2026-09-14
|
||||
status: completed
|
||||
requirements:
|
||||
- REQ-TASKS-QUEUE-ADMISSION-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/queue-admission.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Queue admission reliability
|
||||
|
||||
## Overview
|
||||
|
||||
Repair ordinary composer queue submissions for [issue #3663](https://github.com/kdlbs/kandev/issues/3663).
|
||||
Durable server replay protection, client recovery, localized feedback, and desktop and phone verification are complete.
|
||||
|
||||
## Evidence and root cause
|
||||
|
||||
Inspected commit `ba960f973` on 2026-09-14. The issue has no attachments or comments.
|
||||
The authenticated user is `carlosflorencio`, now assigned to the issue.
|
||||
|
||||
- `use-message-handler.ts`, `submitMessageWithAdmission`, supplies `clientQueueId` only when `planCommentRefs.length > 0`.
|
||||
- `use-queue-admission.ts` forwards only supplied IDs and tolerates a committed refresh failure only for identified submissions.
|
||||
- `queue-api.ts` omits the third timeout argument on initial and retry requests. Recovery requires a supplied `client_queue_id`.
|
||||
- `wsQueueMessage` uses replay-aware admission only when plan-comment references exist. Ordinary `admitQueuedMessage` ignores `ClientQueueID`.
|
||||
- `chat-input-area.tsx`, `showMessageSendToast`, treats errors outside `MessageSendError` as unknown connection failures, including `QueueFullError`.
|
||||
- `client.ts` starts the request timer before sending. A disconnected send enters `pendingQueue`; timeout removes its waiter, not its buffered frame.
|
||||
|
||||
A temporary Vitest reproduction called real `queueMessage` with an ordinary payload and a mocked timeout.
|
||||
It confirmed one request, two request arguments, no client ID, and immediate rejection without reconciliation.
|
||||
The reproduction plus existing plan-comment API and message-handler suites passed: 3 files, 37 tests.
|
||||
The temporary file was removed. At that investigation stage, no production or permanent test changes had been made.
|
||||
|
||||
The issue's claim that a timeout proves the request never reaches the server is not established.
|
||||
A late buffered request can still arrive. This makes server deduplication a prerequisite for safe retries.
|
||||
The issue's blanket claim of absent reconciliation is outdated for plan-comment submissions.
|
||||
|
||||
## Requirement conformance and assumptions
|
||||
|
||||
The task system owns this capability because it owns admission identity and durable prompt storage.
|
||||
Existing resume requirements cover startup, and plan-comment requirements cover selected comments.
|
||||
Neither defines recoverable ordinary admission during RUNNING. The new queue-admission requirement defines that missing contract.
|
||||
The existing merge requirement remains authoritative; routing ordinary prompts through the comment-only path would violate its intended behavior.
|
||||
|
||||
Confirmed scope: investigation, assignment, implementation, and verification. No user interview was needed to preserve the existing queue and draft behavior.
|
||||
The receipt design and alternatives are recorded in the ADR. The implementation follows the sequential work orders.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Optional existing `client_queue_id` support for ordinary server admission.
|
||||
- Atomic receipts, replay conflict handling, lifecycle cleanup, and database parity.
|
||||
- Explicit 10s/30s budgets, bounded recovery, and one automatic retry.
|
||||
- Typed rejection feedback, submitted-draft preservation, and desktop/mobile evidence.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Provider execution guarantees, offline outboxes, and browser-reload recovery of unacknowledged drafts.
|
||||
- Global WebSocket buffer redesign, new settings, and merge-policy changes.
|
||||
- Retrying an uncertain draft after a later deliberate Send with the previous identity.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Apply the [queue-admission design](../../specs/tasks/system-design/queue-admission.md) and
|
||||
[receipt ADR](../../decisions/2026-09-14-durable-queue-admission-receipts.md).
|
||||
The server transaction is the correctness boundary. Receipt lookup precedes mutable admission checks, after authorization and incarnation validation.
|
||||
Client queue/transcript reads accelerate recovery but are not the deduplication authority.
|
||||
Keep optional unidentified-client compatibility and plan-comment behavior.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Existing composer after failed queue admission. Shared structure for desktop Task chat and Quick Chat, and their phone composers.
|
||||
|
||||
```text
|
||||
Before (capacity rejection):
|
||||
[Message send status unknown]
|
||||
[The connection dropped or timed out]
|
||||
[Draft and attachments retained] [Send]
|
||||
|
||||
After (capacity rejection):
|
||||
[Message not sent]
|
||||
[The message queue is full.]
|
||||
[Draft and attachments retained] [Send]
|
||||
|
||||
After (unresolved transport):
|
||||
[Message send status unknown]
|
||||
[The connection dropped or timed out]
|
||||
[Draft and attachments retained] [Send]
|
||||
|
||||
After (confirmed acceptance):
|
||||
[Existing queue chip or transcript]
|
||||
[Empty submitted draft] [Send]
|
||||
```
|
||||
|
||||
Copy is illustrative and must use locale catalogs. Hierarchy and outcome distinctions are required by AC .5, .6, and .8.
|
||||
Phone uses the existing toast and inline composer, wrapping text within the viewport.
|
||||
Reuse `chat-input-toolbar-mobile.tsx` and `mobile-message-queue-management.spec.ts` as the nearest shipped examples.
|
||||
No new sheet, navigation, scrolling region, or control is introduced. Preserve the existing safe area and touch target.
|
||||
|
||||
## Tests
|
||||
|
||||
All suffixes reference `AC-TASKS-QUEUE-ADMISSION-001`.
|
||||
|
||||
| Criteria | Required regression evidence |
|
||||
| --- | --- |
|
||||
| .1, .4 | `use-message-handler.test.ts`: ordinary queue sends carry a stable ID; `queue-api.admission.test.ts`: 10s/30s initial and retry budgets |
|
||||
| .2, .3 | `repository_admission_test.go`: `TestQueueAdmissionReplay`, `TestQueueAdmissionConflict`, `TestQueueAdmissionConcurrentReplay`, `TestQueueAdmissionLifecycle` |
|
||||
| .2, .7 | `TestQueueAdmissionMergeReplay`, `TestQueueAdmissionAttachmentReplay`; existing merge and plan-comment suites |
|
||||
| .5 | `use-queue.plan-comments.test.ts` and new admission tests: accepted mutation plus failed refresh; preserve unsuccessful drafts |
|
||||
| .6, .8 | `chat-input-area.test.tsx` and desktop/mobile E2E: rejection versus uncertainty and retained attachments |
|
||||
|
||||
New test files and names are implementation targets. TDD must first show failures for the specified missing behavior.
|
||||
The temporary diagnostic test asserted the then-current behavior; it is not the implementation regression assertion.
|
||||
|
||||
## E2E tests
|
||||
|
||||
Add `e2e/tests/chat/queue-admission-reliability.spec.ts` for chromium and
|
||||
`e2e/tests/chat/mobile-queue-admission-reliability.spec.ts` for mobile-chrome.
|
||||
Use isolated fixture backends and real admission. Intercept only selected WebSocket frames to delay or drop requests/responses deterministically.
|
||||
Cover a 6-second response, lost accepted response, pre-server lost request, queue-full rejection, and final uncertainty.
|
||||
Assert content is admitted once, automatic attempts are capped, and draft/attachments match the outcome.
|
||||
Include ordinary Auto-merge ON and OFF, attachment admission, Task chat and Quick Chat, and a real phone tap.
|
||||
Unit tests cover the 30-second timer boundary without adding long browser waits.
|
||||
Record expected injected transport errors with the harness rather than globally disabling strict WS accounting.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Durable ordinary queue admission](task-01-durable-admission.md) (completed)
|
||||
- [x] [Task 02: Composer recovery and feedback](task-02-composer-recovery.md) (completed)
|
||||
|
||||
Executed sequentially. Task 02 depended on the server replay guarantee from Task 01.
|
||||
|
||||
## Verification results
|
||||
|
||||
Diagnostic evidence: 3 Vitest files, 37 tests passed. Temporary reproduction removed.
|
||||
Specification validation passed: 268 decisions and 904 specifications validated. All specification files passed.
|
||||
`git diff --check` passed. The package inventory contains two completed work orders.
|
||||
Implementation checks passed: backend build and lint, focused backend and race suites, SQL guard, persistence store conformance, 146 focused web tests, typecheck, web lint, i18n validation, production build, and the desktop and mobile admission E2E suites.
|
||||
Review remediation checks passed: structured WebSocket conflict preservation, scoped admission error mapping, ordinary transcript provenance, staged-upload rejection, full-fold acceptance timestamps with replay stability, session-incarnation fencing, persistence conformance coverage, accepted comment refresh handling, and pseudo-locale component-tag preservation.
|
||||
The Postgres admission parity test was discovered and skipped because `KANDEV_TEST_POSTGRES_DSN` was not set; it remains available for configured database CI.
|
||||
|
||||
## Risks
|
||||
|
||||
- A later deliberate Send after uncertainty can duplicate the original admission; this package does not promise otherwise.
|
||||
- Deployment must update the server before enabling ordinary client retries. Mixed older backends do not honor ordinary IDs.
|
||||
- PostgreSQL parity still needs execution in an environment with `KANDEV_TEST_POSTGRES_DSN`.
|
||||
- Public session guidance now documents the shipped admission and recovery behavior.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
id: "01-durable-admission"
|
||||
title: "Durable ordinary queue admission"
|
||||
status: completed
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-QUEUE-ADMISSION-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.1
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.2
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.3
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.7
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/queue-admission.md
|
||||
---
|
||||
|
||||
# Task 01: Durable ordinary queue admission
|
||||
|
||||
## Summary
|
||||
|
||||
Add replay-safe ordinary admission using the existing optional client queue ID.
|
||||
Keep receipt persistence atomic with insertion, merge, and attachment claims.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add typed ordinary admission input and receipt storage under the current task/session locks.
|
||||
- Validate all client IDs and request fingerprints. Preserve unidentified and plan-comment callers.
|
||||
- Return replay success before capacity, mutable reference checks, or repeated upload claims, after authorization and identity checks.
|
||||
- Preserve Auto-merge ON/OFF, full-capacity fold, staged claim exclusion, order, and Auto-run behavior.
|
||||
- Add additive SQLite/PostgreSQL schema and session/task cleanup. Update scoped guidance if a new queue persistence convention needs documentation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Client retries, copy, composer UI, and global WebSocket buffering.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Exact concurrent replay yields one content admission and one attachment claim; changed payload or stale identity never mutates work.
|
||||
2. Replay survives merge, reservation, dispatch, remove, clear, and restart. Session deletion cleans receipts and reset rejects old incarnation requests.
|
||||
3. Receipt and queue writes roll back together. Existing ordinary merge, capacity, and comment-consumption tests still pass.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root. Add named tests using TDD before production changes.
|
||||
|
||||
```bash
|
||||
(cd apps/backend && go test -tags fts5 ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers -count=1)
|
||||
(cd apps/backend && go test -tags fts5 -race ./internal/orchestrator/messagequeue -run 'TestQueueAdmissionConcurrentReplay|TestQueueAdmissionLifecycle|TestQueueAdmissionAttachmentReplay' -count=1)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Add parity cases to the existing PostgreSQL test harness. Use its configured test database and record any environment skips explicitly.
|
||||
Do not call skipped PostgreSQL tests passing parity evidence.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/handlers/queue_handlers.go`
|
||||
- `apps/backend/internal/orchestrator/handlers/queue_handlers_admission_test.go`
|
||||
- `apps/backend/internal/orchestrator/messagequeue/service.go`
|
||||
- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go`
|
||||
- `apps/backend/internal/orchestrator/messagequeue/repository.go`
|
||||
- `apps/backend/internal/orchestrator/messagequeue/repository_admission.go` (new)
|
||||
- `apps/backend/internal/orchestrator/messagequeue/repository_admission_test.go` (new)
|
||||
- `apps/backend/internal/orchestrator/messagequeue/repository_postgres_durability_test.go`
|
||||
- Queue repository lifecycle deletion helpers located from the current session/task purge call sites.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Server support must exist before Task 02 enables ordinary retries.
|
||||
|
||||
## Risks
|
||||
|
||||
Read-then-insert without database uniqueness is unsafe. Process locks alone do not provide restart or database concurrency guarantees.
|
||||
Storing inline attachment bytes in receipt snapshots duplicates large payloads.
|
||||
A queue-row foreign key with cascading deletion defeats receipt retention.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirement](../../specs/tasks/requirements/queue-admission.md)
|
||||
- [Design](../../specs/tasks/system-design/queue-admission.md), Server admission and Persistence
|
||||
- [Receipt decision](../../decisions/2026-09-14-durable-queue-admission-receipts.md)
|
||||
- `repository_plan_comment.go`, `repository_plan_comment_test.go`, and attachment admission tests as transaction patterns
|
||||
- `apps/backend/AGENTS.md`
|
||||
|
||||
## Results
|
||||
|
||||
Implemented durable ordinary admission across the SQLite and PostgreSQL-backed repositories, with memory parity for tests. Admission receipts bind the request fingerprint to task, session, and incarnation identity; exact replays return the stored result, conflicts fail without mutation, and receipt writes remain atomic with queue insertion, Auto-merge, and attachment claims. Task and session cleanup remove receipts while clear and queue-row removal preserve replay protection.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- `go test -tags fts5 ./internal/orchestrator/messagequeue ./internal/orchestrator/handlers -count=1`
|
||||
- `go test -tags fts5 -race ./internal/orchestrator/messagequeue -run 'TestQueueAdmissionConcurrentReplay|TestQueueAdmissionLifecycle|TestQueueAdmissionAttachmentReplay' -count=1`
|
||||
- `make -C apps/backend build`
|
||||
- `make -C apps/backend lint`
|
||||
- `go run ./cmd/sqlguard ./internal`
|
||||
- `go test -race ./internal/persistence/storeconformance`
|
||||
- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.py --all`, and `git diff --check`
|
||||
|
||||
The PostgreSQL receipt durability test was added and run with `-v`; it skipped because `KANDEV_TEST_POSTGRES_DSN` was not set.
|
||||
|
||||
Review remediation passed: full-capacity identified admissions with staged attachment IDs return `queue_full` before any claim or Auto-merge mutation, and memory admission follows the same rule. Full-capacity folds now assign one acceptance timestamp before insertion or folding, with SQLite and memory regressions proving the stored timestamp advances and exact replay does not advance it again. Memory replay conflicts report `replay=false`, staged claims fail deterministically when the memory repository cannot claim attachments, and the session-incarnation regression retains the old receipt while admitting the same client ID under the replacement incarnation. The required-table catalog includes admission receipts, and the store-conformance action exercises the owning admission and lookup APIs.
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
id: "02-composer-recovery"
|
||||
title: "Composer recovery and feedback"
|
||||
status: completed
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-durable-admission"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-QUEUE-ADMISSION-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.1
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.4
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.5
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.6
|
||||
- AC-TASKS-QUEUE-ADMISSION-001.8
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/queue-admission.md
|
||||
---
|
||||
|
||||
# Task 02: Composer recovery and feedback
|
||||
|
||||
## Summary
|
||||
|
||||
Give ordinary queue submissions a stable admission ID and explicit timeout budget.
|
||||
Recover uncertain submissions once, and distinguish rejection from uncertainty without losing drafts.
|
||||
|
||||
## In scope
|
||||
|
||||
- Pass `clientAdmissionId` unconditionally from the message handler's queue branch.
|
||||
- Use the same request closure, ID, immutable identity, payload, and numeric 10s/30s timeout for both attempts.
|
||||
- Bound reconciliation and reconnection as specified in the design. Do not retry explicit first-attempt server rejection.
|
||||
- Keep committed admission successful when terminal queue refetch fails. Reject stale responses through existing operation tokens.
|
||||
- Preserve typed queue codes and localize rejection feedback through the existing composer surfaces.
|
||||
- Add all five locale values and generate Traditional Chinese using the repository script.
|
||||
- Add unit and desktop/mobile browser regressions, then update `docs/public/sessions-and-review.md` with shipped error/recovery behavior.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New layout, automatic submission after reload, and durable browser outboxes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Ordinary and comment-bearing queue submissions retain one ID and payload through at most one automatic retry, with correct numeric timeouts.
|
||||
2. Confirmed acceptance clears only the submitted draft. Rejection or final uncertainty retains text and attachments with the correct localized feedback.
|
||||
3. Task chat and Quick Chat pass desktop and phone recovery tests against the real replay-safe backend, including Auto-merge and attachment scenarios.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Shared composer outcome, excerpt from the [full preview](plan.md#ascii-ui-preview).
|
||||
|
||||
```text
|
||||
Capacity rejection: [Message not sent] [The message queue is full.]
|
||||
Unresolved delivery: [Message send status unknown] [Connection feedback]
|
||||
[Retained draft + attachments] [Send]
|
||||
Accepted delivery: [Existing queue chip or transcript] [Cleared draft]
|
||||
```
|
||||
|
||||
AC .5, .6, .8 require the distinction and retained input. Text is illustrative and localized.
|
||||
Desktop and phone reuse the existing toast/composer hierarchy. Phone wraps copy and keeps the current safe area and 44-pixel Send target.
|
||||
No new scroll owner or navigation is introduced. Compare rendered screenshots with this structure during the browser scenarios.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root. If workspace dependencies are absent, first run `(cd apps && pnpm install --frozen-lockfile)`.
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm exec vitest run hooks/use-message-handler.test.ts hooks/use-message-handler.plan-comments.test.ts hooks/domains/session/use-queue.test.ts hooks/domains/session/use-queue.plan-comments.test.ts lib/api/domains/queue-api.test.ts lib/api/domains/queue-api.plan-comments.test.ts lib/api/domains/queue-api.admission.test.ts components/task/chat/chat-input-area.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/chat/queue-admission-reliability.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-queue-admission-reliability.spec.ts)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Add every new helper test to the targeted Vitest command before marking done.
|
||||
Use fake timers for budgets. Browser tests must delay/drop only selected frames and preserve real backend admission.
|
||||
See the plan for the required E2E scenario matrix. Run projects sequentially through the managed builder.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/use-message-handler.ts` and `.test.ts`
|
||||
- `apps/web/hooks/domains/session/use-queue-admission.ts`
|
||||
- `apps/web/hooks/domains/session/use-queue.test.ts` and `use-queue.plan-comments.test.ts`
|
||||
- `apps/web/lib/api/domains/queue-api.ts`, `queue-api.test.ts`, `queue-api.plan-comments.test.ts`
|
||||
- `apps/web/lib/api/domains/queue-api.admission.test.ts` (new)
|
||||
- `apps/web/lib/chat/message-send-error.ts` and its test if shared codes change
|
||||
- `apps/web/components/task/chat/chat-input-area.tsx` and `chat-input-area.test.tsx`
|
||||
- `apps/web/components/task/passthrough-chat-composer.tsx` if the shared error audit identifies a consumer
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`
|
||||
- `apps/web/e2e/tests/chat/queue-admission-reliability.spec.ts` (new)
|
||||
- `apps/web/e2e/tests/chat/mobile-queue-admission-reliability.spec.ts` (new)
|
||||
- `docs/public/sessions-and-review.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 must pass. Do not enable ordinary replay against a backend that ignores the admission ID.
|
||||
|
||||
## Risks
|
||||
|
||||
A successful replay response can refer to a removed or merged source. Refresh authoritative state instead of adding a phantom queue row.
|
||||
A new explicit submission after uncertainty is not an automatic retry and can duplicate earlier accepted content.
|
||||
Unbounded transcript pagination delays useful feedback. Raw server errors are not localized UI copy.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirement](../../specs/tasks/requirements/queue-admission.md)
|
||||
- [Design](../../specs/tasks/system-design/queue-admission.md), Client recovery and Composer feedback
|
||||
- Existing queue plan-comment API tests and message-handler tests
|
||||
- `apps/web/e2e/tests/chat/message-queue.spec.ts` and `mobile-message-queue-management.spec.ts`
|
||||
- `apps/web/AGENTS.md`, `/mobile-parity`, `/e2e`, and `/tdd`
|
||||
|
||||
## Results
|
||||
|
||||
Implemented stable ordinary admission IDs, 10-second and 30-second request budgets, bounded reconciliation with one same-identity retry, typed queue rejection errors, draft and attachment preservation, and localized composer feedback. Task chat and Quick Chat share the recovery behavior, with desktop and mobile browser coverage. Added the five locale catalogs and updated the public sessions guide.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- The targeted Vitest command above: 8 files, 146 tests.
|
||||
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and `pnpm run build`.
|
||||
- `pnpm run e2e:sleep-ratchet`.
|
||||
- Chromium admission E2E: 4 passed.
|
||||
- Mobile Chrome admission E2E: 2 passed.
|
||||
- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.py --all`, and `git diff --check`.
|
||||
|
||||
Review remediation passed: unrecognized structured WebSocket conflicts preserve their original error identity, code, and details for plan-comment and primary-session recovery. Admission-only WebSocket mappings are scoped to queue submission, identified missing sessions use the typed unavailable code, and accepted queue submissions remain successful when the plan-comment refresh fails. Ordinary admission IDs are retained through queue merge and transcript recording so post-dispatch reconciliation can find them. The pseudo-locale retains the named `count` component tags, and unrelated generated catalog churn was removed.
|
||||
@@ -75,6 +75,10 @@ You can write and send the next prompt while an existing session is starting or
|
||||
|
||||
If Auto-run is OFF, the prompt remains pending until you turn Auto-run ON in the queue controls. If resume fails, the accepted prompt stays in the queue with the session and is available after a later successful recovery. Environment preparation without a usable session queue does not enable Send.
|
||||
|
||||
When you queue a prompt, Kandev records a submission identity before it reports success. The browser waits up to 10 seconds for a prompt without attachments and 30 seconds when attachments are included. If the connection result is uncertain, it checks the queue and recent conversation, then retries the same submission once. The server uses the identity to prevent duplicate entries.
|
||||
|
||||
A full queue, invalid content or attachment, identity conflict, or unavailable session shows a **Message not sent** error and keeps the draft and attachments. If delivery remains uncertain, Kandev shows **Message send status unknown** and keeps them for inspection. A confirmed admission clears only the submitted draft; a failed queue refresh does not turn an accepted admission into a failure.
|
||||
|
||||
Every row has **Send Now** for targeted priority. It sends that row directly when the session is promptable or replaces the captured active turn after backend cancellation acknowledgement. A successful Send Now turns Auto-run ON, runs the selected row first, then continues the remaining rows as separate FIFO turns without ordinary Cancel side effects. **Clear all** discards the visible queue. The chat toolbar's **Cancel** immediately stops the active turn, sends no queued prompt, parks any pending backlog by turning Auto-run OFF, and can complete the workflow step or move the task to review.
|
||||
|
||||
A CLI-passthrough profile displays the agent's native terminal interface in a PTY. It still belongs to the task, but it does not provide Kandev's structured chat messages and tool-call presentation.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
status: active
|
||||
system: tasks
|
||||
created: 2026-09-14
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Queue Admission Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Users need a reliable result when submitting a prompt to a busy session.
|
||||
The task system owns admission identity, persistence, and recovery. Composers expose this contract on desktop and phone.
|
||||
|
||||
An admission is one submission attempt, including its automatic retry. Queue editing, merging, and dispatch are later operations.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-QUEUE-ADMISSION-001: Recoverable queue submission
|
||||
|
||||
**Intent:** Preserve user input and prevent duplicate admission when a response is lost.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.1:** Every ordinary composer queue submission shall have a stable admission identity, including submissions without plan comments.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.2:** Repeating an accepted admission with the same identity and payload shall not append content, claim attachments, or consume comments again.
|
||||
This applies after automatic merge, manual removal, queue clearing, reservation, dispatch, and backend restart.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.3:** Reusing an admission identity with a different payload shall fail without changing the accepted work.
|
||||
An admission shall remain bound to its original task, session, and session incarnation.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.4:** A queue submission shall allow 10 seconds for a response, or 30 seconds when it includes attachments.
|
||||
After uncertain transport failure, the client shall reconcile acceptance and retry at most once with the same identity and payload.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.5:** Confirmed acceptance shall clear only the submitted draft. A failed queue refresh after acceptance shall not report admission failure.
|
||||
Rejected or unresolved submissions shall preserve the draft and attachments. A later unrelated draft shall remain unchanged.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.6:** Queue capacity and validation rejections shall show localized rejection feedback.
|
||||
An unresolved timeout or connection loss shall show uncertain delivery feedback without claiming that the server rejected the message.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.7:** Reliable admission shall preserve Auto-merge, capacity, attachment ownership, Auto-run, and plan-comment consumption rules.
|
||||
In particular, a compatible ordinary submission shall remain eligible for automatic merging.
|
||||
- **AC-TASKS-QUEUE-ADMISSION-001.8:** Task chat and Quick Chat shall provide equivalent recovery and draft preservation on desktop and phone.
|
||||
Phone feedback shall remain readable with the existing touch-accessible Send action.
|
||||
|
||||
## Compatibility and exclusions
|
||||
|
||||
Clients that omit admission identity retain their existing behavior without automatic replay guarantees.
|
||||
Existing plan-comment identities remain valid. A second deliberate submission is a new admission, not a replay of an edited draft.
|
||||
No provider-level exactly-once execution or offline outbox is promised. Browser reload does not restore an unacknowledged submission automatically.
|
||||
|
||||
## Related contracts
|
||||
|
||||
- [Resume prompt queue](resume-prompt-queue.md) owns startup eligibility and deferred dispatch.
|
||||
- [Plan comments](plan-comments.md) owns comment selection and atomic consumption.
|
||||
- [Automatic merge overrides](../../ui/requirements/message-queue-auto-merge-session-overrides.md) owns merge policy and capacity exceptions.
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Queue admission reliability](../../../plans/queue-admission-reliability/plan.md)
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-QUEUE-ADMISSION-001
|
||||
---
|
||||
|
||||
# Queue Admission System Design
|
||||
|
||||
## Boundary and mapping
|
||||
|
||||
The task system owns durable prompt admission. This design extends the existing queue transport and identity guards.
|
||||
|
||||
| Criteria (AC-TASKS-QUEUE-ADMISSION-001) | Sections |
|
||||
| --- | --- |
|
||||
| .1, .2, .3, .7 | Server admission, Persistence |
|
||||
| .4, .5 | Client recovery |
|
||||
| .6, .8 | Composer feedback |
|
||||
|
||||
## Server admission
|
||||
|
||||
`QueueHandlers.wsQueueMessage` already accepts optional `client_queue_id`. Preserve this field rather than adding a parallel `client_message_id`.
|
||||
Validate its length for every identified admission. Keep legacy unidentified callers on the existing path.
|
||||
Plan-comment admissions retain their existing transaction and merge exclusion.
|
||||
|
||||
For ordinary identified requests, add a typed admission request to the messagequeue service.
|
||||
Capture the immutable `QueueSessionIdentity`, normalized payload, and caller identity. Fingerprint content, model, plan mode, attachments, context, references, and admission options.
|
||||
Exclude request IDs, timeouts, and transport timestamps. Use the existing canonical fingerprint utility.
|
||||
|
||||
Under the existing task/session admission guards, validate authorization and incarnation before reading any receipt.
|
||||
An exact receipt replay returns acceptance without changing queue state. A mismatched fingerprint returns a typed conflict.
|
||||
Replay lookup precedes capacity evaluation and attachment claim preparation that can reject already-claimed uploads.
|
||||
Mutable reference validation applies to a first admission. Exact replay must not fail because an accepted reference later changed.
|
||||
|
||||
For a new admission, keep the existing insertion, full-capacity candidate fold, and staged attachment rules.
|
||||
Write the receipt in the same transaction as the accepted insertion or fold and any attachment ownership changes.
|
||||
A transaction failure commits neither content nor receipt. A database uniqueness constraint closes concurrent replay races.
|
||||
Do not implement a separate receipt write after queue commit or a read-then-insert deduplication check outside the transaction.
|
||||
|
||||
Store ordinary contributing IDs in a provenance metadata key that is ignored by merge compatibility and unioned by automatic and manual folds. Ordinary entries must retain their current compatibility behavior, while their provenance remains available after a source row is folded or dispatched.
|
||||
The earlier tail still survives automatic merging. A receipt identifies admission, not the continued existence of a visible source row.
|
||||
Post-admission readiness checks retain current behavior and cannot convert committed acceptance into rejection.
|
||||
|
||||
## Persistence
|
||||
|
||||
Add a queue-owned `queue_admission_receipts` table through `repository_sqlite.go` initialization and a focused repository helper.
|
||||
The shared SQL implementation must work with SQLite and PostgreSQL using established rebinding and lock conventions.
|
||||
|
||||
Use `(task_id, session_id, session_incarnation_id, client_queue_id)` as the receipt key.
|
||||
Store the normalized request fingerprint, accepted queue ID, bounded response snapshot, and acceptance timestamp.
|
||||
The response retains the current `QueuedMessage` shape. Omit inline attachment data from stored and replayed snapshots.
|
||||
Clients already hold the submitted attachment payload and refresh authoritative queue state separately.
|
||||
|
||||
Retain receipts after manual edits, merges, removal, clearing, reservation, and dispatch. These operations do not reverse admission.
|
||||
Session/task deletion purges associated receipts through existing lifecycle transactions. Reset invalidates old incarnation requests before replay lookup.
|
||||
Session transfer must not retarget a receipt. Reject an obsolete original identity under current ownership guards.
|
||||
Do not add a time-based expiry that allows a delayed duplicate within a retained session.
|
||||
Existing rows need no backfill. Historical unidentified requests have no replay guarantee.
|
||||
|
||||
## Client recovery
|
||||
|
||||
`useMessageHandler` passes its existing `clientAdmissionId` for every queue submission, not only plan-comment submissions.
|
||||
`useQueueAdmissionAction` preserves that ID and treats acknowledged admission as successful even if its terminal queue refresh fails.
|
||||
`queueMessage` keeps an explicitly supplied ID. It does not generate a replacement ID inside retry handling.
|
||||
|
||||
Use a shared request closure with numeric timeout `10000`, or `30000` for nonempty attachments, for both attempts.
|
||||
Classify timeout and connection closure using existing transport errors. Explicit server rejection is not a transport failure.
|
||||
Keep existing queue/transcript reconciliation as an optimization. Bound ordinary reconciliation to one queue read and one newest transcript page per pass.
|
||||
Each read has a 5-second timeout. Wait at most 3 seconds for reconnection before one replay-safe retry.
|
||||
After retry failure, one bounded reconciliation pass can confirm acceptance. Otherwise propagate typed rejection or uncertain delivery.
|
||||
The server receipt, not exhaustive transcript scanning, supplies deduplication when the original row has disappeared.
|
||||
Preserve existing plan-comment recovery coverage while extracting any shared helper.
|
||||
|
||||
A returned replay snapshot must not insert a phantom pending row into the store.
|
||||
Use the existing authoritative queue refresh and incarnation-aware mutation token.
|
||||
After unresolved failure, preserve the draft. A later deliberate Send remains a new admission and can duplicate an earlier uncertain submission.
|
||||
Feedback must not claim otherwise. Persistent draft retry identities are outside this package.
|
||||
|
||||
## Composer feedback
|
||||
|
||||
Keep typed queue error codes through the API boundary. Map capacity, validation, identity conflict, and unavailable-session errors to localized rejection copy.
|
||||
Do not display raw server diagnostics as translated UI copy. Preserve existing plan-comment-specific normalization.
|
||||
Only an unresolved transport outcome uses the existing uncertain-delivery message.
|
||||
Use shared failure presentation for Task chat and Quick Chat. Audit the passthrough composer if it receives the same error types.
|
||||
|
||||
No new controls or layout are needed. Reuse the current toast, composer, and `chat-input-toolbar-mobile.tsx`.
|
||||
The phone Task layout and Quick Chat retain their scroll ownership, safe-area spacing, and 44-pixel Send target.
|
||||
The plan's UI-01 preview defines the affected error state. Add desktop and phone rendered regression coverage.
|
||||
|
||||
## Security and observability
|
||||
|
||||
Apply existing queue authorization before receipt lookup. Never disclose another task's receipt by a guessed key.
|
||||
Fingerprinting and receipt handling must not log prompt content, attachment bytes, or credentials.
|
||||
Use bounded structured fields for initial acceptance, replay, conflict, and uncertain client recovery.
|
||||
No new metrics, high-cardinality labels, or global transport logging are required.
|
||||
|
||||
## Related decisions and implementation
|
||||
|
||||
- [Durable admission receipts](../../../decisions/2026-09-14-durable-queue-admission-receipts.md) records the accepted persistence choice.
|
||||
- [Server-owned Auto-run](../../../decisions/2026-08-16-server-owned-queue-auto-run.md) remains authoritative for dispatch.
|
||||
- [Implementation plan](../../../plans/queue-admission-reliability/plan.md)
|
||||
Reference in New Issue
Block a user