From d0ba785c678c0c2bb5fa2241542354211186d1be Mon Sep 17 00:00:00 2001 From: xgxgx Date: Thu, 30 Jul 2026 22:53:34 +0800 Subject: [PATCH 01/23] fix(sessions): reliably deliver startup user messages via event queue Close the #189 startup window where accepted user messages were only written to session_events and never reached code_session_inbound_events. Record startup delivery responsibility in session_event_queue in the same transaction as session_events, then atomically hand off the full queue, clear it, and activate the Code Session under the same Session row lock used by Send Events. Reject extra ordinary startup user messages with 409. --- .../be/session-startup-message-delivery.md | 408 ++++++++++++ .../managed_agent_code_session.go | 51 +- internal/codesessions/service.go | 43 +- internal/db/code_sessions.go | 161 ++++- internal/db/db.go | 2 + internal/db/deployments.go | 9 + .../00036_add_session_event_queue.sql | 19 + internal/db/session_event_queue.go | 316 +++++++++ internal/db/sessions.go | 13 +- internal/db/sessions_migration_sqlx_test.go | 10 + internal/db/sessions_sqlx.go | 6 + internal/environments/runner.go | 32 - internal/sessions/service.go | 53 +- internal/sessions/service_helpers.go | 29 +- tests/deployments_api_test.go | 73 +++ tests/environments_runner_cloud_test.go | 85 +++ tests/sessions_api_test.go | 607 ++++++++++++++++++ 17 files changed, 1818 insertions(+), 99 deletions(-) create mode 100644 docs/design/be/session-startup-message-delivery.md create mode 100644 internal/db/migrations/00036_add_session_event_queue.sql create mode 100644 internal/db/session_event_queue.go diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md new file mode 100644 index 00000000..e97235b1 --- /dev/null +++ b/docs/design/be/session-startup-message-delivery.md @@ -0,0 +1,408 @@ +# Session 启动期消息可靠投递 + +## 一句话结论 + +Session 对外仍然立即返回 `idle`;在 Code Session 可用前收到的第一条 +`user.message`,由 `session_events` 和 `session_event_queue` 在同一事务中接收,随后在 +Code Session 激活事务中与 queue 清理一起原子写入 inbound,保证 API 返回成功的消息不会 +丢失。 + +## 目标与边界 + +### 目标 + +修复 Issue #189:Runner prepare 之后、Code Session 创建或激活之前发送的消息,API 已经 +返回 200,但消息没有进入 Code Session inbound。 + +必须保证: + +1. 启动窗口内成功接收的第一条 `user.message` 最终进入对应 Code Session; +2. 普通 Send Events 在整个启动窗口内最多成功接收一条 `user.message`; +3. queue 交接完成以前 Code Session 不得变成 `active`; +4. queue 交接失败时不删除责任记录,也不留下部分 inbound; +5. active 后保持原有“只实时投递当前 batch”的行为。 + +### 不改变的行为 + +- 不增加 `starting` 等公开 Session 状态,客户端仍看到现有 `idle`; +- 不阻止客户端在 Session 创建后调用 Send Events; +- 不扫描完整 `session_events`,不维护 watermark; +- 不在 worker register 或 heartbeat 中补投; +- 不把 queue 扩展成永久投递历史或通用 outbox; +- 不改变 active 路径的事件转换、tool confirmation 和 batch 范围。 + +## 修改前后差异 + +| 关注点 | 修改前 | 修改后 | +| --- | --- | --- | +| 启动期事件来源 | Runner prepare 时读取一次 `InitialEvents` 快照 | Send Events 提交时记录明确的 queue 引用 | +| prepare 后到达的消息 | 不在旧快照中 | 与 `session_events` 同事务进入 queue | +| Code Session 创建 | 使用 prepare 阶段快照写 inbound | 只读取 `session_event_queue` 指向的事件 | +| 消息责任 | API 200 后没有跨启动流程的持久化责任 | queue row 持有到最终激活事务提交 | +| 激活条件 | 快照处理完成后继续启动 | inbound、清空 queue、切 `active` 一次提交 | +| active 后发送 | 实时投递当前 batch | 保持不变 | + +变化的核心不是“创建 Code Session 时扫描更多历史”,而是把投递责任前移到接收消息的事务: +只要启动期 Send Events 返回 200,数据库中就同时存在公开事件事实和一条尚未完成的交接 +责任。 + +## 修改前:消息为什么会丢 + +Runner 在 prepare 阶段读取事件快照。prepare 后还要执行资源准备、依赖安装和 sandbox +启动,此时 Code Session 尚不存在。窗口内的新消息只能写入 `session_events`:它不在旧快照 +中,也没有 inbound 目标。 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant API as Sessions API + participant Events as session_events + participant Runner + participant CS as Code Session + participant Inbound as code_session_inbound_events + + Client->>API: Create Session + API-->>Client: 200, status=idle + Runner->>Events: prepare 时读取 InitialEvents + Events-->>Runner: snapshot + Note over Runner,CS: 准备资源和 sandbox,Code Session 尚不存在 + Client->>API: Send user.message + API->>Events: INSERT user.message + API-->>Client: 200 + Runner->>CS: 使用旧 snapshot 创建 Code Session + Runner->>Inbound: INSERT initialize + snapshot events + Note over Events,Inbound: 新 user.message 不在 snapshot,也没有进入 inbound +``` + +## 修改后:端到端主流程 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant API as Sessions API + participant Session as sessions row + participant Events as session_events + participant Queue as session_event_queue + participant Runner + participant CS as code_sessions + participant Inbound as code_session_inbound_events + + Client->>API: Create Session + API-->>Client: 200, status=idle + Runner->>Runner: prepare config/resources(不读事件快照) + + Client->>API: Send one user.message + API->>Session: SELECT FOR UPDATE + API->>API: 判断仍在启动窗口且 queue 为空 + API->>Events: INSERT user.message + API->>Queue: INSERT session_event_uuid + API->>API: COMMIT event + queue + API-->>Client: 200 + + Runner->>CS: INSERT status=initializing + Runner->>Inbound: INSERT initialize (sequence=1) + Runner->>Queue: 读取完整 queue 快照(FIFO) + Queue-->>Runner: queue items + Runner->>Events: 按 Session ownership 加载 event payloads + Runner->>Runner: 转换为 inbound inputs + + rect rgb(238, 247, 255) + Note over Session,CS: 最终激活事务 + Runner->>Session: SELECT FOR UPDATE + Runner->>CS: SELECT initializing FOR UPDATE + Runner->>Queue: SELECT full queue FOR UPDATE + Runner->>Runner: 与事务外快照逐项比较 + loop queue.id 顺序 + Runner->>Inbound: INSERT inbound + end + Runner->>Queue: DELETE all rows + Runner->>CS: UPDATE initializing → active + Runner->>Runner: COMMIT + end + + Note over Client,Inbound: inbound 顺序为 initialize + startup user messages +``` + +## 数据模型与职责 + +| 数据 | 事实含义 | 生命周期 | +| --- | --- | --- | +| `session_events` | Session API 已接受的公开事件 | 按现有 Session 事件生命周期保留 | +| `session_event_queue` | 事件尚未完成启动期 Code Session 交接 | Send 事务创建,激活事务删除 | +| `code_session_inbound_events` | 某个具体 Code Session 可以消费的输入 | 按现有 inbound 生命周期保留 | + +`session_event_queue` 是临时责任表,不是 payload 副本。它只保存: + +- queue 自身顺序 `id`; +- `session_uuid`; +- `session_event_uuid`; +- organization/workspace 作用域; +- 创建时间。 + +表中没有 payload、delivery status、重试次数或 delivered history。事件内容始终从 +`session_events` 读取,避免形成第二份事件事实源。`session_event_uuid` 唯一,防止同一公开 +事件重复获得两条 queue 责任。 + +读取 queue 引用时必须同时匹配 organization、workspace、Session ID、Session external ID +和 event UUID。queue 如果指向其他 Session 的事件,创建流程直接失败,不得写 inbound、删 +queue 或激活 Code Session。 + +## 启动窗口判定 + +启动窗口是后端事务内的判断,不是新的公开状态。 + +`sessionUserMessageStartupWindowSQLX` 的规则是: + +1. 查询该 Session 最新且未删除的 Code Session; +2. 如果 Code Session 存在且状态不是 `initializing`,不进入 startup queue; +3. 如果 Code Session 不存在或仍为 `initializing`,检查对应 Environment Work; +4. work data 必须指向当前 Session,且状态为 `queued`、`starting` 或 `active`; +5. Environment 类型不参与判断; +6. work 已停止或最新 Code Session 是其他状态时,保持既有事件行为。 + +这个判断必须在 Send 事务锁住 Session 行之后执行,不能在 API 层提前查询。发送和激活只有 +使用同一条 Session 行作为串行化边界,才能关闭最后一次 queue 检查与 `active` 切换之间的 +竞态。 + +## 普通 Send Events + +### API 层:只做内存计算 + +`Handler.sendEventsRoute` 先调用 `normalizeInputEvent` 生成 `SessionEvent`。如果 batch 中有 +`user.define_outcome`,只更新内存里的 `normalizedSession.OutcomeEvaluations`,不在 +normalization 阶段写库。 + +随后一次性调用 `DB.AppendSessionEventsForDelivery`,传入: + +- 标准化后的事件 batch; +- 可选的最终 outcome evaluations。 + +这样被 409 拒绝的 batch 不会提前修改 outcome。 + +### DB 层:同一事务接收事实与责任 + +`AppendSessionEventsForDelivery`: + +1. `SELECT ... FOR UPDATE` 锁定 Session; +2. 拒绝 archived Session; +3. batch 包含 `user.message` 时判断启动窗口; +4. 启动窗口只允许 batch 恰好包含一条 `user.message`; +5. queue 已有任何 row 时返回 `ErrSessionStartupMessageConflict`; +6. 写入 `session_events`; +7. 启动窗口内再写入对应 queue row; +8. 有 outcome 变化时在同一事务更新; +9. commit 后返回 `startup_queued` 或 `realtime`。 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant API as sendEventsRoute + participant DB as AppendSessionEventsForDelivery + participant Session as sessions row + participant Events as session_events + participant Queue as session_event_queue + + Client->>API: Send Events + API->>API: normalize events/outcome(内存) + API->>DB: events + optional outcome + DB->>Session: SELECT FOR UPDATE + + alt 启动窗口且不是单条 user.message + DB-->>API: startup conflict + API-->>Client: 409,无持久化副作用 + else 启动窗口且 queue 非空 + DB-->>API: startup conflict + API-->>Client: 409,无持久化副作用 + else 启动窗口且允许接收 + DB->>Events: INSERT event + DB->>Queue: INSERT event reference + DB->>DB: optional outcome UPDATE + COMMIT + DB-->>API: startup_queued + API-->>Client: 200 + else 非启动窗口 + DB->>Events: INSERT current batch + DB->>DB: optional outcome UPDATE + COMMIT + DB-->>API: realtime + API->>API: 仅向 active Code Session 投当前 batch + API-->>Client: 200 + end +``` + +由于普通发送与激活都锁 Session: + +- 两个并发启动消息会被串行化; +- 第一个看到空 queue 并返回 200; +- 第二个看到仍然保留的 queue row 并返回 409; +- queue row 一直保留到 Code Session `active` 的同一事务,因此整个启动窗口累计只能成功接收 + 一条普通 `user.message`,不是“同一时刻只有一条 pending”。 + +## Code Session 创建与原子激活 + +`Service.CreateManagedAgentCodeSession` 的顺序为: + +1. 创建状态为 `initializing` 的 Code Session; +2. 写入 sequence 1 的 `initialize` inbound; +3. 调用 `activateManagedAgentCodeSession` 完成 queue 交接; +4. 激活成功后才继续签发并返回 runtime 启动信息; +5. 中途失败时,现有 defer cleanup 将未完成的 Code Session terminate。 + +### 阶段一:读取并转换快照 + +`ListSessionEventQueueItems` 按 queue `id` 升序读取完整列表,并根据每个 +`session_event_uuid` 加载属于当前 Session 的正式事件。Service 在事务外将这些公开事件转换 +成 Code Session inbound inputs。 + +转换放在事务外,避免在持有 Session 和 Code Session 行锁时执行 JSON/envelope 处理。 + +### 阶段二:一个事务完成全部交接 + +`ActivateManagedAgentCodeSessionWithQueue` 固定执行: + +```text +锁 Session +→ 锁 initializing Code Session +→ 校验每个 queue item 的 Session ownership 和 user.message 类型 +→ 锁当前完整 queue +→ 将当前 queue 与事务外快照逐项比较 +→ 按 queue.id 顺序写入全部 inbound +→ 删除当前 Session 的全部 queue rows +→ Code Session initializing → active +→ commit +``` + +快照比较包括 row 数量、queue ID、Session UUID、event UUID 和顺序。如果读取快照以后新消息 +进入 queue,最终事务会发现列表不一致,返回 `activated=false`,且不做任何写入;Service +重新读取、转换并重试。 + +inbound 插入复用现有 idempotency key。事务内每写一条新 inbound 都推进当前 Code Session +的 sequence,保证多条 Deployment initial messages 保持 FIFO 顺序。 + +## 激活 cutover 的并发语义 + +发送与激活都先锁同一条 Session 行,只会出现两种提交顺序。 + +```mermaid +sequenceDiagram + autonumber + participant Send as Send transaction + participant Activate as Activation transaction + participant Session as sessions row + participant Queue as session_event_queue + participant CS as code_sessions + participant Inbound as code_session_inbound_events + + alt Send 先获得 Session 锁 + Send->>Session: SELECT FOR UPDATE + Activate->>Session: 等待 + Send->>Queue: INSERT event reference + Send->>Send: COMMIT + Activate->>Session: 获得锁 + Activate->>Queue: 快照不一致 + Activate-->>Activate: rollback and retry + Activate->>Inbound: 交接包含新消息的完整 queue + Activate->>Queue: DELETE all + Activate->>CS: UPDATE active + COMMIT + else Activate 先获得 Session 锁 + Activate->>Session: SELECT FOR UPDATE + Send->>Session: 等待 + Activate->>Queue: 锁定并确认完整 queue + Activate->>Inbound: INSERT all startup inputs + Activate->>Queue: DELETE all + Activate->>CS: UPDATE active + COMMIT + Send->>Session: 获得锁并看到 active + Send->>Inbound: realtime current batch + end +``` + +因此不存在可观察的“queue 已被删除,但 Code Session 仍是 `initializing`”窗口:删除 queue 和 +切换 active 属于同一个事务。 + +## Deployment initial events + +Deployment 创建 Session 时,Session、initial events、queue 和 Deployment Run 已位于同一 +事务。该路径: + +1. 按输入顺序写入全部 `session_events`; +2. 如果属于启动窗口,把其中所有 `user.message` 按相同顺序写入 queue; +3. 不应用普通 Send Events 的单条限制; +4. 非 `user.message` 保留为公开事件,但不进入这个窄 queue; +5. 任一步失败都回滚整个 Deployment 创建事务。 + +Code Session 激活事务一次性交接完整 queue,因此 inbound 顺序为: + +```text +initialize +→ initial user message 1 +→ initial user message 2 +→ ... +``` + +## Active 实时路径 + +`AppendSessionEventsForDelivery` 返回 `realtime` 后,API 只把本次创建的 events 传给 +`Service.QueuePublicSessionEvents`。 + +该方法重新读取最新 Code Session,并且只有 `status == active` 时才写 inbound。不存在 Code +Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态时直接返回,不向其写入 +实时事件。 + +实时路径继续复用已有的: + +- 可转发事件过滤; +- `user.tool_confirmation` control response; +- worker payload envelope; +- inbound idempotency; +- 当前 batch 范围。 + +## 失败与重试语义 + +| 失败位置 | 结果 | +| --- | --- | +| 启动期 batch 不是单条 `user.message` | 返回 409;event、queue、outcome 均不写入 | +| 启动期已有 queue row | 返回 409;请求无持久化副作用 | +| event、queue 或 outcome 写入失败 | Send 事务整体回滚 | +| queue event 不属于当前 Session | 创建失败;不写 inbound、不删 queue、不激活 | +| 事件转换失败 | 创建失败;queue 保留 | +| 快照与锁定后的完整 queue 不一致 | 激活事务无写入,重新读取并重试 | +| 任一 inbound 写入或 sequence 更新失败 | 激活事务整体回滚,queue 保留,状态仍为 `initializing` | +| queue 删除数量不等于快照数量 | 激活事务回滚并返回 precondition error | +| active 更新没有恰好影响一行 | 激活事务回滚 | +| Code Session 创建流程失败 | 现有 cleanup terminate 未完成的 Code Session | +| active 后实时投递失败 | 保持既有行为;公开事件已提交,本设计不增加通用 outbox | + +## 代码入口 + +| 设计步骤 | 代码入口 | +| --- | --- | +| Runner prepare 不再读取事件快照 | `Runner.prepareManagedAgentLaunch` | +| API 标准化事件与 outcome | `Handler.sendEventsRoute`、`normalizeInputEvent` | +| Send 事务和 startup/realtime 分流 | `DB.AppendSessionEventsForDelivery` | +| 启动窗口判断 | `sessionUserMessageStartupWindowSQLX` | +| queue 写入 | `enqueueSessionEventsSQLXTx` | +| queue 快照及 ownership 加载 | `DB.ListSessionEventQueueItems` | +| Code Session 创建和消费循环 | `Service.CreateManagedAgentCodeSession`、`activateManagedAgentCodeSession` | +| 完整 queue 原子交接与激活 | `DB.ActivateManagedAgentCodeSessionWithQueue` | +| Deployment initial events 入队 | `DB.CreateManualDeploymentRun` | +| active 当前 batch 投递 | `Service.QueuePublicSessionEvents` | + +## 验收矩阵 + +| 场景 | 预期 | +| --- | --- | +| Runner prepare 后、Code Session 创建前发送消息 | API 200;event 与 queue 同事务写入;最终进入 inbound | +| 普通启动消息已在 queue,再发送第二条 | 409;不新增 event、queue 或 outcome | +| 两条普通启动消息并发 | Session 行锁串行化;一个 200,一个 409 | +| 启动期多事件 batch 包含 `user.message` | 整体 409,无部分副作用 | +| 被拒绝 batch 包含 `user.define_outcome` | outcome 不变化 | +| queue 快照后新消息进入 | 第一次激活不写入并重试,最终完整交接 | +| queue 引用另一个 Session 的 event | 拒绝激活,queue 保留 | +| 第二条 inbound 写入失败 | 第一条 inbound 也回滚,queue 全部保留,Code Session 仍 initializing | +| Deployment 包含多条 initial user messages | `initialize` 后按输入顺序写入全部消息 | +| 激活事务先于 Send 获得 Session 锁 | 激活原子提交,后续消息走 realtime | +| Send 事务先于激活获得 Session 锁 | 消息进入 queue,激活发现变化后重试 | +| Code Session 已 active | 不写 startup queue,只实时投当前 batch | +| Code Session 非 active | 不实时写 inbound | +| Environment 类型不同 | 不影响 Session 启动窗口判定 | diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 7e15e1fa..120658ab 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -23,7 +23,6 @@ type ManagedAgentCreateInput struct { PermissionMode string DangerouslySkipPermissions bool Config json.RawMessage - InitialEvents []json.RawMessage } // ManagedAgentCreateResult 只在创建链路内短暂携带两份明文凭证,调用方应立即交给 @@ -66,7 +65,7 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag WorkDir: strings.TrimSpace(input.WorkDir), PermissionMode: strings.TrimSpace(input.PermissionMode), Model: strings.TrimSpace(input.Model), - Status: "active", + Status: "initializing", Metadata: metadata, // OAuth-compatible token 只落 SHA-256 hash;明文仅存在于当前返回值中。 OAuthAccessTokenHash: auth.HashAPIKey(oauthAccessToken), @@ -99,7 +98,7 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag if err := s.queueInitialize(ctx, record, input.Config, now); err != nil { return ManagedAgentCreateResult{}, err } - if err := s.queueInitialPublicSessionEvents(ctx, record, input.InitialEvents, now); err != nil { + if err := s.activateManagedAgentCodeSession(ctx, input.Session, record); err != nil { return ManagedAgentCreateResult{}, err } credentialContext, err := s.db.GetCodeSessionCredentialContextForIssue( @@ -126,6 +125,52 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag }, nil } +// activateManagedAgentCodeSession hands off only explicitly queued startup +// messages, then activates while holding the same Session lock as send. +func (s *Service) activateManagedAgentCodeSession( + ctx context.Context, + session db.Session, + codeSession db.CodeSession, +) error { + for { + items, err := s.db.ListSessionEventQueueItems(ctx, session) + if err != nil { + return err + } + inputs := make([]db.AppendCodeSessionEventInput, 0, len(items)) + for _, item := range items { + if item.Event.EventType != "user.message" { + return errors.New("session event queue contains a non-user message") + } + payload, err := workerPayloadForPublicEvent( + codeSession.ExternalID, + item.Event.Payload, + item.Event.ProcessedAt, + ) + if err != nil { + return err + } + inbound, err := newInboundEventInput(codeSession.ExternalID, payload, "public-session") + if err != nil { + return err + } + inputs = append(inputs, inbound) + } + activated, err := s.db.ActivateManagedAgentCodeSessionWithQueue( + ctx, + codeSession, + items, + inputs, + ) + if err != nil { + return err + } + if activated { + return nil + } + } +} + // TerminateManagedAgentCodeSession revokes a Code Session created for a // sandbox launch that failed before the runtime became usable. func (s *Service) TerminateManagedAgentCodeSession( diff --git a/internal/codesessions/service.go b/internal/codesessions/service.go index c27602aa..c6c3a1d2 100644 --- a/internal/codesessions/service.go +++ b/internal/codesessions/service.go @@ -43,30 +43,6 @@ func NewServiceWithCredentials(database *db.DB, credentials *SessionCredentials, return &Service{db: database, credentials: credentials, logger: logger} } -func (s *Service) queueInitialPublicSessionEvents(ctx context.Context, codeSession db.CodeSession, payloads []json.RawMessage, now time.Time) error { - if len(payloads) == 0 { - return nil - } - workerPayloads := make([]json.RawMessage, 0, len(payloads)) - for _, raw := range payloads { - object, err := decodeJSONObject(raw) - if err != nil { - s.logger.WarnContext(ctx, "skip initial code session event", "code_session_id", codeSession.ExternalID, "error", err) - continue - } - if !forwardPublicEventToWorker(stringField(object, "type")) { - continue - } - payload, err := workerPayloadForPublicEvent(codeSession.ExternalID, raw, now) - if err != nil { - s.logger.ErrorContext(ctx, "convert initial code session event", "code_session_id", codeSession.ExternalID, "error", err) - continue - } - workerPayloads = append(workerPayloads, payload) - } - return s.QueueRawPublicSessionEvents(ctx, codeSession, workerPayloads) -} - func (s *Service) QueuePublicSessionEvents(ctx context.Context, session db.Session, events []db.SessionEvent) error { if s == nil || len(events) == 0 { return nil @@ -78,6 +54,9 @@ func (s *Service) QueuePublicSessionEvents(ctx context.Context, session db.Sessi } return err } + if codeSession.Status != "active" { + return nil + } payloads := make([]json.RawMessage, 0, len(events)) for _, event := range events { if !forwardPublicEventToWorker(event.EventType) { @@ -324,15 +303,23 @@ func (s *Service) queueInitialize(ctx context.Context, codeSession db.CodeSessio } func (s *Service) appendInboundPayload(ctx context.Context, codeSessionID string, payload json.RawMessage, source string) (db.CodeSessionEvent, bool, error) { - meta, err := BuildEventMetadata(codeSessionID, "inbound", payload) + input, err := newInboundEventInput(codeSessionID, payload, source) if err != nil { return db.CodeSessionEvent{}, false, err } + return s.db.AppendCodeSessionInboundEvent(ctx, codeSessionID, input) +} + +func newInboundEventInput(codeSessionID string, payload json.RawMessage, source string) (db.AppendCodeSessionEventInput, error) { + meta, err := BuildEventMetadata(codeSessionID, "inbound", payload) + if err != nil { + return db.AppendCodeSessionEventInput{}, err + } eventID, err := ids.New("csev_") if err != nil { - return db.CodeSessionEvent{}, false, err + return db.AppendCodeSessionEventInput{}, err } - return s.db.AppendCodeSessionInboundEvent(ctx, codeSessionID, db.AppendCodeSessionEventInput{ + return db.AppendCodeSessionEventInput{ ExternalID: eventID, EventType: meta.EventType, EventSubtype: meta.EventSubtype, @@ -344,7 +331,7 @@ func (s *Service) appendInboundPayload(ctx context.Context, codeSessionID string DeliveryStatus: "queued", Source: strings.TrimSpace(source), CreatedAt: time.Now().UTC(), - }) + }, nil } func (s *Service) publishPublicPayloads(ctx context.Context, codeSessionID string, payloads []json.RawMessage) error { diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 115da944..57fec8f3 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -8,6 +8,8 @@ import ( "errors" "strings" "time" + + "github.com/jmoiron/sqlx" ) type CodeSession struct { @@ -282,6 +284,138 @@ func (d *DB) CreateCodeSession(ctx context.Context, input CreateCodeSessionInput }) } +// ActivateManagedAgentCodeSessionWithQueue atomically transfers the complete +// startup queue to inbound, clears the temporary responsibility, and activates +// the Code Session while holding the same Session lock as event sends. +func (d *DB) ActivateManagedAgentCodeSessionWithQueue( + ctx context.Context, + codeSession CodeSession, + items []SessionEventQueueItem, + inputs []AppendCodeSessionEventInput, +) (bool, error) { + if len(items) != len(inputs) { + return false, ErrInvalidState + } + tx, err := d.sql.BeginTxx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + + session, err := getSessionSQLX( + ctx, + tx, + lockSessionForEventsQuery, + sessionLookupArguments(codeSession.WorkspaceID, codeSession.SessionExternalID), + ) + if err != nil { + return false, err + } + current, err := getCodeSessionSQLX(ctx, tx, ` + select `+codeSessionColumns()+` + from code_sessions + where organization_id = :organization_id + and workspace_id = :workspace_id + and external_id = :external_id + and session_id = :session_id + and status = 'initializing' + and deleted_at is null + for update + `, map[string]any{ + "organization_id": codeSession.OrganizationID, + "workspace_id": codeSession.WorkspaceID, + "external_id": codeSession.ExternalID, + "session_id": session.ID, + }) + if err != nil { + return false, err + } + for _, item := range items { + if item.sessionUUID != session.UUID || + item.Event.UUID != item.sessionEventUUID || + item.Event.OrganizationID != session.OrganizationID || + item.Event.WorkspaceID != session.WorkspaceID || + item.Event.SessionID != session.ID || + item.Event.SessionExternalID != session.ExternalID || + item.Event.EventType != "user.message" { + return false, ErrInvalidState + } + } + queueRows, err := listSessionEventQueueIdentityRows(ctx, tx, session, true) + if err != nil { + return false, err + } + if !sessionEventQueueItemsMatch(queueRows, items) { + return false, nil + } + + for _, input := range inputs { + inserted, duplicate, err := d.appendCodeSessionEventSQLXTx(ctx, tx, current, "inbound", input) + if err != nil { + return false, err + } + if duplicate && inserted.CodeSessionExternalID != current.ExternalID { + return false, ErrInvalidState + } + if !duplicate { + current.LastInboundSequenceNum = inserted.SequenceNum + } + } + deletedResult, err := namedExecContext(ctx, tx, ` + delete from session_event_queue + where organization_id = :organization_id + and workspace_id = :workspace_id + and session_uuid = CAST(:session_uuid AS uuid) + `, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "session_uuid": session.UUID, + }) + if err != nil { + return false, err + } + deleted, err := deletedResult.RowsAffected() + if err != nil { + return false, err + } + if deleted != int64(len(items)) { + return false, ErrPreconditionFailed + } + + result, err := namedExecContext(ctx, tx, ` + update code_sessions + set status = 'active', updated_at = :now + where organization_id = :organization_id + and workspace_id = :workspace_id + and id = :id + and external_id = :external_id + and session_id = :session_id + and status = 'initializing' + and deleted_at is null + `, map[string]any{ + "organization_id": current.OrganizationID, + "workspace_id": current.WorkspaceID, + "id": current.ID, + "external_id": current.ExternalID, + "session_id": session.ID, + "now": time.Now().UTC(), + }) + if err != nil { + return false, err + } + updated, err := result.RowsAffected() + if err != nil { + return false, err + } + if updated != 1 { + return false, ErrInvalidState + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} + // codeSessionCredentialContextSelect 查询 code session 的鉴权身份信息。 // OAuth token 鉴权和 session-ingress JWT 签发都会使用这些信息。 // JOIN 中同时校验 organization、workspace 和 session 的归属,防止跨租户查询。 @@ -826,13 +960,30 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS if err != nil { return CodeSessionEvent{}, false, err } + event, duplicate, err := d.appendCodeSessionEventSQLXTx(ctx, tx, session, direction, input) + if err != nil { + return CodeSessionEvent{}, false, err + } + if err := tx.Commit(); err != nil { + return CodeSessionEvent{}, false, err + } + return event, duplicate, nil +} + +func (d *DB) appendCodeSessionEventSQLXTx( + ctx context.Context, + tx *sqlx.Tx, + session CodeSession, + direction string, + input AppendCodeSessionEventInput, +) (CodeSessionEvent, bool, error) { if input.RequiredWorkerEpoch != nil && session.CurrentWorkerEpoch != *input.RequiredWorkerEpoch { return CodeSessionEvent{}, false, ErrWorkerEpochMismatch } if input.IdempotencyKey != "" { existing, err := d.getCodeSessionEventTx(ctx, tx, direction, session.WorkspaceID, input.IdempotencyKey) if err == nil { - return existing, true, tx.Commit() + return existing, true, nil } if !errors.Is(err, ErrNotFound) { return CodeSessionEvent{}, false, err @@ -854,7 +1005,10 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS deliveryStatus = "queued" } - var event CodeSessionEvent + var ( + event CodeSessionEvent + err error + ) eventArguments := map[string]any{ "external_id": input.ExternalID, "organization_id": session.OrganizationID, @@ -915,9 +1069,6 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS }); err != nil { return CodeSessionEvent{}, false, err } - if err := tx.Commit(); err != nil { - return CodeSessionEvent{}, false, err - } return event, false, nil } diff --git a/internal/db/db.go b/internal/db/db.go index f8d59f21..8ba2f2e9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -35,6 +35,8 @@ var ( ErrFileReferenceNotFound = errors.New("file reference not found") ) +var ErrSessionStartupMessageConflict = errors.New("session startup message conflict") + type DB struct { Pool *pgxpool.Pool sql *sqlx.DB diff --git a/internal/db/deployments.go b/internal/db/deployments.go index 5a36d8c7..94a07bf5 100644 --- a/internal/db/deployments.go +++ b/internal/db/deployments.go @@ -282,6 +282,15 @@ func (d *DB) CreateManualDeploymentRun(ctx context.Context, input CreateManualDe if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } + startup, err := sessionUserMessageStartupWindowSQLX(ctx, tx, session) + if err != nil { + return DeploymentRun{}, Session{}, SessionThread{}, nil, err + } + if startup { + if err := enqueueSessionEventsSQLXTx(ctx, tx, session, events); err != nil { + return DeploymentRun{}, Session{}, SessionThread{}, nil, err + } + } run := input.Run run.DeploymentID = deployment.ID diff --git a/internal/db/migrations/00036_add_session_event_queue.sql b/internal/db/migrations/00036_add_session_event_queue.sql new file mode 100644 index 00000000..37cc51c3 --- /dev/null +++ b/internal/db/migrations/00036_add_session_event_queue.sql @@ -0,0 +1,19 @@ +-- +goose Up +create table session_event_queue ( + id bigint generated always as identity, + uuid uuid not null default gen_random_uuid(), + organization_id bigint not null, + workspace_id bigint not null, + session_uuid uuid not null, + session_event_uuid uuid not null, + created_at timestamptz not null default now(), + constraint session_event_queue_id_pk primary key (id), + constraint session_event_queue_uuid_key unique (uuid), + constraint session_event_queue_session_event_uuid_key unique (session_event_uuid) +); + +create index session_event_queue_session_order_v1_idx + on session_event_queue (organization_id, workspace_id, session_uuid, id asc); + +-- +goose Down +drop table if exists session_event_queue; diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go new file mode 100644 index 00000000..2f0a6f4d --- /dev/null +++ b/internal/db/session_event_queue.go @@ -0,0 +1,316 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" +) + +type SessionEventDelivery string + +const ( + SessionEventDeliveryRealtime SessionEventDelivery = "realtime" + SessionEventDeliveryStartupQueued SessionEventDelivery = "startup_queued" +) + +// SessionEventQueueItem couples one temporary queue identity with its owned +// public Session event. +type SessionEventQueueItem struct { + id int64 + sessionUUID string + sessionEventUUID string + Event SessionEvent +} + +type sessionEventQueueIdentityRow struct { + ID int64 `db:"id"` + SessionUUID string `db:"session_uuid"` + SessionEventUUID string `db:"session_event_uuid"` +} + +// AppendSessionEventsForDelivery keeps the existing delivery path outside the +// managed-agent startup window. During startup it accepts exactly one +// user.message with an empty queue and records the public event and temporary +// delivery responsibility in the same transaction. +func (d *DB) AppendSessionEventsForDelivery( + ctx context.Context, + workspaceID int64, + sessionExternalID string, + events []SessionEvent, + outcomeEvaluations json.RawMessage, +) ([]SessionEvent, SessionEventDelivery, error) { + tx, err := d.sql.BeginTxx(ctx, nil) + if err != nil { + return nil, "", err + } + defer tx.Rollback() + + session, err := getSessionSQLX( + ctx, + tx, + lockSessionForEventsQuery, + sessionLookupArguments(workspaceID, sessionExternalID), + ) + if err != nil { + return nil, "", err + } + if session.ArchivedAt != nil { + return nil, "", ErrInvalidState + } + userMessageCount := 0 + for _, event := range events { + if event.EventType == "user.message" { + userMessageCount++ + } + } + startup := false + if userMessageCount > 0 { + startup, err = sessionUserMessageStartupWindowSQLX(ctx, tx, session) + if err != nil { + return nil, "", err + } + } + if startup { + if len(events) != 1 || userMessageCount != 1 { + return nil, "", ErrSessionStartupMessageConflict + } + pending, err := sessionEventQueueExistsSQLX(ctx, tx, session) + if err != nil { + return nil, "", err + } + if pending { + return nil, "", ErrSessionStartupMessageConflict + } + } + + created, err := insertSessionEventsSQLXTx(ctx, tx, session, events, false) + if err != nil { + return nil, "", err + } + delivery := SessionEventDeliveryRealtime + if startup { + if err := enqueueSessionEventsSQLXTx(ctx, tx, session, created); err != nil { + return nil, "", err + } + delivery = SessionEventDeliveryStartupQueued + } + if len(outcomeEvaluations) > 0 { + if _, err := getSessionSQLX(ctx, tx, setSessionOutcomeEvaluationsQuery, map[string]any{ + "workspace_id": session.WorkspaceID, + "session_external_id": session.ExternalID, + "outcome_evaluations": jsonArg(outcomeEvaluations), + }); err != nil { + return nil, "", err + } + } + if err := tx.Commit(); err != nil { + return nil, "", err + } + return created, delivery, nil +} + +// ListSessionEventQueueItems returns the current startup queue in FIFO order +// and rejects references that do not belong to the supplied Session. +func (d *DB) ListSessionEventQueueItems( + ctx context.Context, + session Session, +) ([]SessionEventQueueItem, error) { + rows, err := listSessionEventQueueIdentityRows(ctx, d.sql, session, false) + if err != nil { + return nil, err + } + items := make([]SessionEventQueueItem, 0, len(rows)) + for _, row := range rows { + event, err := getSessionEventSQLX(ctx, d.sql, ` + select `+sessionEventSQLXColumns+` + from session_events + where organization_id = :organization_id + and workspace_id = :workspace_id + and uuid = CAST(:session_event_uuid AS uuid) + and session_id = :session_id + and session_external_id = :session_external_id + and deleted_at is null + `, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "session_event_uuid": row.SessionEventUUID, + "session_id": session.ID, + "session_external_id": session.ExternalID, + }) + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf( + "%w: queued event %s does not belong to Session %s", + ErrInvalidState, + row.SessionEventUUID, + session.ExternalID, + ) + } + if err != nil { + return nil, err + } + items = append(items, SessionEventQueueItem{ + id: row.ID, + sessionUUID: row.SessionUUID, + sessionEventUUID: row.SessionEventUUID, + Event: event, + }) + } + return items, nil +} + +func listSessionEventQueueIdentityRows( + ctx context.Context, + database sqlxNamedQueryer, + session Session, + lock bool, +) ([]sessionEventQueueIdentityRow, error) { + query := ` + select id, CAST(session_uuid AS text) as session_uuid, + CAST(session_event_uuid AS text) as session_event_uuid + from session_event_queue + where organization_id = :organization_id + and workspace_id = :workspace_id + and session_uuid = CAST(:session_uuid AS uuid) + order by id asc + ` + if lock { + query += ` for update` + } + var rows []sessionEventQueueIdentityRow + err := namedSelectContext(ctx, database, &rows, query, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "session_uuid": session.UUID, + }) + if err != nil { + return nil, err + } + return rows, nil +} + +func sessionEventQueueItemsMatch( + rows []sessionEventQueueIdentityRow, + items []SessionEventQueueItem, +) bool { + if len(rows) != len(items) { + return false + } + for i := range rows { + if rows[i].ID != items[i].id || + rows[i].SessionUUID != items[i].sessionUUID || + rows[i].SessionEventUUID != items[i].sessionEventUUID { + return false + } + } + return true +} + +func sessionUserMessageStartupWindowSQLX( + ctx context.Context, + database sqlxNamedQueryer, + session Session, +) (bool, error) { + var status string + err := namedGetContext(ctx, database, &status, ` + select status + from code_sessions + where organization_id = :organization_id + and workspace_id = :workspace_id + and session_id = :session_id + and deleted_at is null + order by created_at desc, id desc + limit 1 + `, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "session_id": session.ID, + }) + if err == nil && status != "initializing" { + return false, nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err + } + + var startupWorkExists bool + err = namedGetContext(ctx, database, &startupWorkExists, ` + select exists ( + select 1 + from environment_work + where organization_id = :organization_id + and workspace_id = :workspace_id + and environment_id = :environment_id + and environment_external_id = :environment_external_id + and data->>'type' = 'session' + and data->>'id' = :session_external_id + and state in ('queued', 'starting', 'active') + and deleted_at is null + ) + `, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "environment_id": session.EnvironmentID, + "environment_external_id": session.EnvironmentExternalID, + "session_external_id": session.ExternalID, + }) + if err != nil { + return false, err + } + return startupWorkExists, nil +} + +func sessionEventQueueExistsSQLX( + ctx context.Context, + database sqlxNamedQueryer, + session Session, +) (bool, error) { + var exists bool + err := namedGetContext(ctx, database, &exists, ` + select exists ( + select 1 + from session_event_queue + where organization_id = :organization_id + and workspace_id = :workspace_id + and session_uuid = CAST(:session_uuid AS uuid) + ) + `, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "session_uuid": session.UUID, + }) + return exists, err +} + +func enqueueSessionEventsSQLXTx( + ctx context.Context, + tx *sqlx.Tx, + session Session, + events []SessionEvent, +) error { + for _, event := range events { + if event.EventType != "user.message" { + continue + } + if _, err := namedExecContext(ctx, tx, ` + insert into session_event_queue ( + organization_id, workspace_id, session_uuid, session_event_uuid + ) + values ( + :organization_id, :workspace_id, + CAST(:session_uuid AS uuid), CAST(:session_event_uuid AS uuid) + ) + `, map[string]any{ + "organization_id": session.OrganizationID, + "workspace_id": session.WorkspaceID, + "session_uuid": session.UUID, + "session_event_uuid": event.UUID, + }); err != nil { + return err + } + } + return nil +} diff --git a/internal/db/sessions.go b/internal/db/sessions.go index 0b0aeb22..65df0ca4 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -245,14 +245,6 @@ func (d *DB) PatchSessionMetadata(ctx context.Context, workspaceID int64, extern }) } -func (d *DB) SetSessionOutcomeEvaluations(ctx context.Context, workspaceID int64, externalID string, evaluations json.RawMessage) (Session, error) { - return getSessionSQLX(ctx, d.sql, setSessionOutcomeEvaluationsQuery, map[string]any{ - "workspace_id": workspaceID, - "session_external_id": externalID, - "outcome_evaluations": jsonArg(evaluations), - }) -} - func (d *DB) SetSessionStatus(ctx context.Context, workspaceID int64, externalID, status string) error { rowsAffected, err := namedExecRowsAffected(ctx, d.sql, setSessionStatusQuery, map[string]any{ "workspace_id": workspaceID, @@ -332,6 +324,11 @@ func (d *DB) DeleteSession(ctx context.Context, workspaceID int64, externalID st if _, err := namedExecContext(ctx, tx, deleteSessionResourcesQuery, arguments); err != nil { return Session{}, err } + arguments["organization_id"] = session.OrganizationID + arguments["session_uuid"] = session.UUID + if _, err := namedExecContext(ctx, tx, deleteSessionEventQueueQuery, arguments); err != nil { + return Session{}, err + } if _, err := namedExecContext(ctx, tx, deleteSessionEventsQuery, arguments); err != nil { return Session{}, err } diff --git a/internal/db/sessions_migration_sqlx_test.go b/internal/db/sessions_migration_sqlx_test.go index 6fecf644..412b2c1c 100644 --- a/internal/db/sessions_migration_sqlx_test.go +++ b/internal/db/sessions_migration_sqlx_test.go @@ -118,6 +118,16 @@ func TestMigratedSessionQueriesBindNamedArguments(t *testing.T) { arguments: sessionLookupArguments(2, "sesn_test"), wantArgCount: 2, }, + { + name: "delete session event queue", + query: deleteSessionEventQueueQuery, + arguments: map[string]any{ + "organization_id": int64(1), + "workspace_id": int64(2), + "session_uuid": "11111111-1111-4111-8111-111111111111", + }, + wantArgCount: 3, + }, { name: "stop environment work", query: stopDeletedSessionEnvironmentWorkQuery, diff --git a/internal/db/sessions_sqlx.go b/internal/db/sessions_sqlx.go index 793a7d22..6f5420d3 100644 --- a/internal/db/sessions_sqlx.go +++ b/internal/db/sessions_sqlx.go @@ -124,6 +124,12 @@ const ( and session_external_id = :session_external_id and deleted_at is null ` + deleteSessionEventQueueQuery = ` + delete from session_event_queue + where organization_id = :organization_id + and workspace_id = :workspace_id + and session_uuid = CAST(:session_uuid AS uuid) + ` stopDeletedSessionEnvironmentWorkQuery = ` update environment_work set state = case when state in ('stopped') then state else 'stopping' end, diff --git a/internal/environments/runner.go b/internal/environments/runner.go index 5a87d1f5..0dad3d74 100644 --- a/internal/environments/runner.go +++ b/internal/environments/runner.go @@ -77,7 +77,6 @@ type Runner struct { type managedAgentLaunchPreparation struct { Session db.Session - InitialEvents []json.RawMessage SessionConfig json.RawMessage WorkDir string Title string @@ -483,10 +482,6 @@ func (r *Runner) prepareManagedAgentLaunch( if err != nil { return nil, err } - events, err := r.sessionEventPayloads(ctx, session) - if err != nil { - return nil, err - } runtimeSkills, err := r.resolveRuntimeSkills(ctx, session) if err != nil { return nil, err @@ -503,7 +498,6 @@ func (r *Runner) prepareManagedAgentLaunch( } return &managedAgentLaunchPreparation{ Session: session, - InitialEvents: events, SessionConfig: sessionConfig, WorkDir: workDir, Title: title, @@ -526,7 +520,6 @@ func (r *Runner) createManagedAgentRuntimeLaunch( PermissionMode: "bypassPermissions", DangerouslySkipPermissions: true, Config: preparation.SessionConfig, - InitialEvents: preparation.InitialEvents, }) if err != nil { return managedAgentRuntimeLaunch{}, err @@ -805,31 +798,6 @@ func (r *Runner) replaceRuntimeSkillArchives( return nil } -func (r *Runner) sessionEventPayloads(ctx context.Context, session db.Session) ([]json.RawMessage, error) { - var out []json.RawMessage - var cursor *db.SessionEventPageCursor - for { - events, hasMore, err := r.db.ListSessionEventsPage(ctx, db.ListSessionEventsPageParams{ - WorkspaceID: session.WorkspaceID, - SessionExternalID: session.ExternalID, - Limit: 100, - Cursor: cursor, - Order: "asc", - }) - if err != nil { - return nil, err - } - for _, event := range events { - out = append(out, append(json.RawMessage(nil), event.Payload...)) - } - if !hasMore || len(events) == 0 { - return out, nil - } - last := events[len(events)-1] - cursor = &db.SessionEventPageCursor{CreatedAt: last.CreatedAt, ID: last.ID} - } -} - func sessionIDFromEnvironmentWork(work db.EnvironmentWork) (string, bool) { var data struct { Type string `json:"type"` diff --git a/internal/sessions/service.go b/internal/sessions/service.go index aa986389..2c3063d7 100644 --- a/internal/sessions/service.go +++ b/internal/sessions/service.go @@ -596,28 +596,42 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() events := make([]db.SessionEvent, 0, len(inputs)) var outcomesChanged bool + normalizedSession := session for _, raw := range inputs { - event, changed, err := h.normalizeInputEvent(r.Context(), session, raw, now) + event, outcomes, changed, err := normalizeInputEvent(normalizedSession, raw, now) if err != nil { writeBadRequest(w, r, err) return } - outcomesChanged = outcomesChanged || changed + if changed { + normalizedSession.OutcomeEvaluations = outcomes + outcomesChanged = true + } events = append(events, event) } - created, err := h.db.AppendSessionEvents(r.Context(), session.WorkspaceID, session.ExternalID, events) + var outcomeEvaluations json.RawMessage + if outcomesChanged { + outcomeEvaluations = normalizedSession.OutcomeEvaluations + } + created, delivery, err := h.db.AppendSessionEventsForDelivery( + r.Context(), + session.WorkspaceID, + session.ExternalID, + events, + outcomeEvaluations, + ) + if errors.Is(err, db.ErrSessionStartupMessageConflict) { + writeStartupMessageConflict(w, r) + return + } if err != nil { - if errors.Is(err, db.ErrInvalidState) { - writeBadRequest(w, r, errors.New("archived sessions do not accept new events")) - return - } - h.writeSessionLoadError(w, r, err, sessionID) + h.writeSendEventsPersistenceError(w, r, err, sessionID) return } for _, event := range created { h.broadcast(event) } - if h.codeSessions != nil { + if h.codeSessions != nil && delivery == db.SessionEventDeliveryRealtime { if err := h.codeSessions.QueuePublicSessionEvents(r.Context(), session, created); err != nil { h.logger.ErrorContext(r.Context(), "queue session events for code session", "session_id", session.ExternalID, "error", err) } @@ -638,6 +652,27 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { httpapi.WriteJSON(w, http.StatusOK, sendEventsResponse{Data: data}) } +func (h *Handler) writeSendEventsPersistenceError( + w http.ResponseWriter, + r *http.Request, + err error, + sessionID string, +) { + if errors.Is(err, db.ErrInvalidState) { + writeBadRequest(w, r, errors.New("archived sessions do not accept new events")) + return + } + h.writeSessionLoadError(w, r, err, sessionID) +} + +func writeStartupMessageConflict(w http.ResponseWriter, r *http.Request) { + httpapi.WriteError(w, r, httpapi.NewError( + http.StatusConflict, + "conflict_error", + "Only one user message can be accepted while the session starts", + )) +} + func (h *Handler) addResourceRoute(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "session_id") if h.isOfficialSDKFixtureSession(r, sessionID) { diff --git a/internal/sessions/service_helpers.go b/internal/sessions/service_helpers.go index 9c91b44a..c83cad3e 100644 --- a/internal/sessions/service_helpers.go +++ b/internal/sessions/service_helpers.go @@ -1,7 +1,6 @@ package sessions import ( - "context" "encoding/json" "errors" "fmt" @@ -223,21 +222,25 @@ func (h *Handler) resourceFromFields( }, nil } -func (h *Handler) normalizeInputEvent(ctx context.Context, session db.Session, raw json.RawMessage, now time.Time) (db.SessionEvent, bool, error) { +func normalizeInputEvent( + session db.Session, + raw json.RawMessage, + now time.Time, +) (db.SessionEvent, json.RawMessage, bool, error) { var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil { - return db.SessionEvent{}, false, errors.New("event must be an object") + return db.SessionEvent{}, nil, false, errors.New("event must be an object") } eventType, _ := payload["type"].(string) if !allowedPublicEventType(eventType) { - return db.SessionEvent{}, false, errors.New("event type is not accepted by this endpoint") + return db.SessionEvent{}, nil, false, errors.New("event type is not accepted by this endpoint") } if err := validatePublicInputEvent(eventType, payload); err != nil { - return db.SessionEvent{}, false, err + return db.SessionEvent{}, nil, false, err } eventID, err := ids.New("sevt_") if err != nil { - return db.SessionEvent{}, false, err + return db.SessionEvent{}, nil, false, err } payload["id"] = eventID payload["processed_at"] = now.Format(time.RFC3339) @@ -253,7 +256,7 @@ func (h *Handler) normalizeInputEvent(ctx context.Context, session db.Session, r if outcomeID == "" { outcomeID, err = ids.New("outc_") if err != nil { - return db.SessionEvent{}, false, err + return db.SessionEvent{}, nil, false, err } payload["outcome_id"] = outcomeID } @@ -262,21 +265,19 @@ func (h *Handler) normalizeInputEvent(ctx context.Context, session db.Session, r maxIterations = int(rawMax) } if maxIterations > 20 { - return db.SessionEvent{}, false, errors.New("max_iterations must be at most 20") + return db.SessionEvent{}, nil, false, errors.New("max_iterations must be at most 20") } payload["max_iterations"] = maxIterations outcomes, err := appendOutcomeEvaluation(session.OutcomeEvaluations, outcomeID, maxIterations, now) if err != nil { - return db.SessionEvent{}, false, err - } - if _, err := h.db.SetSessionOutcomeEvaluations(ctx, session.WorkspaceID, session.ExternalID, outcomes); err != nil { - return db.SessionEvent{}, false, err + return db.SessionEvent{}, nil, false, err } + session.OutcomeEvaluations = outcomes outcomesChanged = true } payloadRaw, err := httpapi.MarshalRaw(payload) if err != nil { - return db.SessionEvent{}, false, err + return db.SessionEvent{}, nil, false, err } return db.SessionEvent{ UUID: uuid.NewString(), @@ -290,7 +291,7 @@ func (h *Handler) normalizeInputEvent(ctx context.Context, session db.Session, r Payload: payloadRaw, ProcessedAt: now, CreatedAt: now, - }, outcomesChanged, nil + }, session.OutcomeEvaluations, outcomesChanged, nil } func allowedPublicEventType(eventType string) bool { diff --git a/tests/deployments_api_test.go b/tests/deployments_api_test.go index 194dd86d..dfe4eb41 100644 --- a/tests/deployments_api_test.go +++ b/tests/deployments_api_test.go @@ -240,6 +240,79 @@ func TestDeploymentsAPI(t *testing.T) { ) }) + t.Run("success initial user messages enter startup queue in order", func(t *testing.T) { + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"deployment-startup-queue-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"deployment-startup-queue-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + deployment := createDeployment(t, app, `{ + "agent":`+quoteJSON(agent.ID)+`, + "environment_id":`+quoteJSON(env.ID)+`, + "name":"deployment startup queue", + "initial_events":[ + {"type":"user.message","content":[{"type":"text","text":"deployment first"}]}, + {"type":"system.message","content":[{"type":"text","text":"public only"}]}, + {"type":"user.message","content":[{"type":"text","text":"deployment second"}]} + ] + }`) + defer cleanupDeploymentRows(t, app, deployment.ID) + run := runDeployment(t, app, deployment.ID) + if run.SessionID == nil || *run.SessionID == "" { + t.Fatalf("deployment startup queue Session ID = nil: %+v", run) + } + defer deleteSession(t, app, *run.SessionID) + + rows, err := app.db.Pool.Query(context.Background(), ` + select e.payload #>> '{content,0,text}' + from session_event_queue q + join sessions s + on s.uuid = q.session_uuid + and s.workspace_id = q.workspace_id + join session_events e + on e.uuid = q.session_event_uuid + and e.workspace_id = q.workspace_id + where s.external_id = $1 + order by q.id asc + `, *run.SessionID) + if err != nil { + t.Fatalf("list deployment startup queue: %v", err) + } + var queuedPrompts []string + for rows.Next() { + var prompt string + if err := rows.Scan(&prompt); err != nil { + rows.Close() + t.Fatalf("scan deployment startup queue: %v", err) + } + queuedPrompts = append(queuedPrompts, prompt) + } + if err := rows.Err(); err != nil { + rows.Close() + t.Fatalf("iterate deployment startup queue: %v", err) + } + rows.Close() + if len(queuedPrompts) != 2 || + queuedPrompts[0] != "deployment first" || + queuedPrompts[1] != "deployment second" { + t.Fatalf("deployment startup queue = %#v, want first then second", queuedPrompts) + } + + codeSessionID := launchLocalCodeSession(t, app, *run.SessionID) + inbound, err := app.db.ListQueuedCodeSessionInboundEvents(context.Background(), codeSessionID) + if err != nil { + t.Fatalf("list deployment startup inbound: %v", err) + } + if len(inbound) != 3 || + inbound[0].EventSubtype != "initialize" || + !strings.Contains(string(inbound[1].Payload), "deployment first") || + !strings.Contains(string(inbound[2].Payload), "deployment second") { + t.Fatalf("deployment startup inbound = %#v, want initialize, first, second", inbound) + } + if remaining := sessionEventQueueEventIDs(t, app, *run.SessionID); len(remaining) != 0 { + t.Fatalf("deployment startup queue after activation = %#v, want empty", remaining) + } + }) + t.Run("success lifecycle manual run session events and run filters", func(t *testing.T) { agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"deployments-api-agent"}`) defer cleanupAgentRows(t, app.db, agent.ID) diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index 8e1999a9..2e2ba350 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -318,6 +318,91 @@ func TestEnvironmentRunnerLaunchesManagedAgentCloudSession(t *testing.T) { } } +// TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation 对应 #189: +// Session 已可发消息,但 Code Session 在 Runner 后半段才创建。 +// 在 provider.Create 时发送(prepare 已完成、Code Session 尚不存在): +// 消息应进入 session_events,且 Runner 结束后也应出现在 code_session_inbound_events +// (initialize 之后)。修复前该窗口内只有 session_events,inbound 往往只有 initialize。 +func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *testing.T) { + ctx := context.Background() + cfg, err := config.Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + app := newTestAppWithStore(t, &cfg, newFakeStore("runner-startup-message-bucket")) + defer app.close() + cfg.CodeSession.SandboxAPIBaseURL = app.baseURL + + agent := createAgent(t, app, `{"model":"claude-opus-4-8","name":"Runner Startup Message Agent"}`) + defer archiveAgent(t, app, agent.ID) + env := createEnvironment(t, app, `{"name":"runner-startup-message-`+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")+`"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + + const prompt = "startup-window message must reach inbound" + ids := getDefaultDBIDs(t, app.db) + var acceptedEventID string + provider := &recordingRunnerProvider{ + sandboxID: "sandbox-runner-startup-message", + beforeCreate: func() { + // 发送时 Code Session 尚不存在,无法走实时入队。 + if _, err := app.db.GetCodeSessionBySessionExternalID(ctx, ids.WorkspaceID, session.ID); !errors.Is(err, db.ErrNotFound) { + t.Fatalf("code session at provider.Create = %v, want ErrNotFound", err) + } + sent := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":`+quoteJSON(prompt)+`}]}]}`, defaultTestKey) + if len(sent.Data) != 1 { + t.Fatalf("accepted events = %#v, want one", sent.Data) + } + acceptedEventID = sessionEventStringField(t, sent.Data[0], "id") + if queued := sessionEventQueueEventIDs(t, app, session.ID); !reflect.DeepEqual(queued, []string{acceptedEventID}) { + t.Fatalf("startup session event queue = %#v, want [%s]", queued, acceptedEventID) + } + }, + } + + processed, err := newManagedAgentRunner(t, app, provider, cfg).RunOnce(ctx, "runner-startup-message-test") + if err != nil || !processed { + t.Fatalf("RunOnce() = (%t, %v), want success", processed, err) + } + + // 公共表应已接收该消息。 + publicEvents := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey) + if !eventPageContains(publicEvents, acceptedEventID) { + t.Fatalf("session_events missing accepted event %q: %+v", acceptedEventID, publicEvents.Data) + } + + // worker 入站队列也应包含同一条用户消息,而不能只有 initialize。 + codeSession, err := app.db.GetCodeSessionBySessionExternalID(ctx, ids.WorkspaceID, session.ID) + if err != nil { + t.Fatalf("load Code Session after runner startup: %v", err) + } + queued, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSession.ExternalID) + if err != nil { + t.Fatalf("list queued inbound events: %v", err) + } + if len(queued) != 2 || + queued[0].EventSubtype != "initialize" || + queued[1].EventType != "user" { + t.Fatalf("inbound = %#v, want initialize then user (public accepted, inbound incomplete)", queued) + } + var payload struct { + UUID string `json:"uuid"` + Message struct { + Content string `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(queued[1].Payload, &payload); err != nil { + t.Fatalf("decode queued user message: %v", err) + } + if payload.UUID != acceptedEventID || payload.Message.Content != prompt { + t.Fatalf("queued user = %#v, want uuid=%q content=%q", payload, acceptedEventID, prompt) + } + if remaining := sessionEventQueueEventIDs(t, app, session.ID); len(remaining) != 0 { + t.Fatalf("remaining startup session event queue = %#v, want empty", remaining) + } +} + func TestEnvironmentRunnerPackageProvisioning(t *testing.T) { t.Run("failure terminates sandbox before manager startup", func(t *testing.T) { provider, processed, err := runPackageEnvironment(t, packageRunnerCase{commandErr: errors.New("gem install failed")}) diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index dac022fb..74244462 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -685,6 +685,580 @@ func TestSessionClaudeCodeTaskEventsMapToCanonicalThreads(t *testing.T) { } } +func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-activation-cutover-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-managed-agent-activation-cutover-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-managed-agent-activation-cutover-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + codeSessionID := launchLocalCodeSession(t, app, session.ID) + + if _, err := app.db.Pool.Exec(ctx, ` + update code_sessions + set status = 'initializing' + where external_id = $1 + `, codeSessionID); err != nil { + t.Fatalf("set code session initializing: %v", err) + } + + ids := getDefaultDBIDs(t, app.db) + sessionRecord, err := app.db.GetSession(ctx, ids.WorkspaceID, session.ID) + if err != nil { + t.Fatalf("load Session for activation: %v", err) + } + staleItems, err := app.db.ListSessionEventQueueItems(ctx, sessionRecord) + if err != nil { + t.Fatalf("list empty Session event queue: %v", err) + } + if len(staleItems) != 0 { + t.Fatalf("initial Session event queue = %#v, want empty", staleItems) + } + codeSession, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("load initializing code session: %v", err) + } + + accepted := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"must be delivered before activation"}]}]}`, defaultTestKey) + if len(accepted.Data) != 1 { + t.Fatalf("accepted session events = %#v, want one", accepted.Data) + } + acceptedEventID := sessionEventStringField(t, accepted.Data[0], "id") + + activated, err := app.db.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, staleItems, nil) + if err != nil { + t.Fatalf("reject stale activation queue snapshot: %v", err) + } + if activated { + t.Fatal("activation succeeded with a stale empty queue snapshot") + } + items, err := app.db.ListSessionEventQueueItems(ctx, sessionRecord) + if err != nil { + t.Fatalf("list Session event queue for activation: %v", err) + } + if len(items) != 1 { + t.Fatalf("Session event queue items = %#v, want one", items) + } + + activated, err = app.db.ActivateManagedAgentCodeSessionWithQueue( + ctx, + codeSession, + items, + []db.AppendCodeSessionEventInput{{ + ExternalID: "csev_activation_" + strings.TrimPrefix(codeSessionID, "cse_"), + EventType: "user", + EventSubtype: "message", + Payload: json.RawMessage(`{"type":"user","uuid":` + quoteJSON(acceptedEventID) + `,"message":{"role":"user","content":"must be delivered before activation"}}`), + PayloadHash: "activation-queue-hash", + IdempotencyKey: "activation-queue:" + acceptedEventID, + DeliveryStatus: "queued", + Source: "public-session", + CreatedAt: time.Now().UTC(), + }}, + ) + if err != nil { + t.Fatalf("activate with queued session event: %v", err) + } + if !activated { + t.Fatal("activation did not commit the queued session event") + } + codeSession, err = app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("reload activated code session: %v", err) + } + if codeSession.Status != "active" { + t.Fatalf("status after activation = %q, want active", codeSession.Status) + } + if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { + t.Fatalf("Session event queue after activation = %#v, want empty", queued) + } + inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil { + t.Fatalf("list inbound after activation: %v", err) + } + if len(inbound) != 2 || !bytes.Contains(inbound[1].Payload, []byte(acceptedEventID)) { + t.Fatalf("inbound after activation = %#v, want initialize and accepted user message", inbound) + } +} + +func TestManagedAgentActivationCutoverRoutesFollowingBatchRealtime(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-activation-realtime-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-managed-agent-activation-realtime-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-managed-agent-activation-realtime-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + codeSessionID := launchLocalCodeSession(t, app, session.ID) + + if _, err := app.db.Pool.Exec(ctx, ` + update code_sessions + set status = 'initializing' + where external_id = $1 + `, codeSessionID); err != nil { + t.Fatalf("set cutover code session initializing: %v", err) + } + codeSession, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("load cutover code session: %v", err) + } + activated, err := app.db.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, nil, nil) + if err != nil || !activated { + t.Fatalf("activate empty startup queue = (%t, %v), want success", activated, err) + } + + sent := sendSessionEvents(t, app, session.ID, `{"events":[ + {"type":"user.message","content":[{"type":"text","text":"post-cutover batch one"}]}, + {"type":"user.message","content":[{"type":"text","text":"post-cutover batch two"}]} + ]}`, defaultTestKey) + if len(sent.Data) != 2 { + t.Fatalf("post-cutover events = %#v, want two", sent.Data) + } + if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { + t.Fatalf("post-cutover startup queue = %#v, want empty", queued) + } + inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil { + t.Fatalf("list post-cutover inbound: %v", err) + } + if len(inbound) != 3 || + !bytes.Contains(inbound[1].Payload, []byte("post-cutover batch one")) || + !bytes.Contains(inbound[2].Payload, []byte("post-cutover batch two")) { + t.Fatalf("post-cutover inbound = %#v, want realtime user batch", inbound) + } +} + +func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-event-queue-rollback-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-event-queue-rollback-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-event-queue-rollback-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + sessionResponse := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, sessionResponse.ID) + codeSessionID := launchLocalCodeSession(t, app, sessionResponse.ID) + + if _, err := app.db.Pool.Exec(ctx, ` + update code_sessions + set status = 'initializing' + where external_id = $1 + `, codeSessionID); err != nil { + t.Fatalf("set rollback code session initializing: %v", err) + } + sendSessionEvents(t, app, sessionResponse.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"queue row must survive"}]}]}`, defaultTestKey) + session, err := app.db.GetSession(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, sessionResponse.ID) + if err != nil { + t.Fatalf("load rollback Session: %v", err) + } + codeSession, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("load rollback Code Session: %v", err) + } + items, err := app.db.ListSessionEventQueueItems(ctx, session) + if err != nil { + t.Fatalf("load rollback queue items: %v", err) + } + if len(items) != 1 { + t.Fatalf("rollback queue items = %#v, want one", items) + } + before, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil || len(before) != 1 { + t.Fatalf("rollback inbound before delivery = (%#v, %v), want initialize", before, err) + } + activated, err := app.db.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, items, []db.AppendCodeSessionEventInput{{ + ExternalID: before[0].ExternalID, + EventType: "user", + EventSubtype: "message", + Payload: json.RawMessage(`{"type":"user","uuid":"rollback-user"}`), + PayloadHash: "rollback-hash", + IdempotencyKey: "rollback-idempotency", + DeliveryStatus: "queued", + Source: "public-session", + CreatedAt: time.Now().UTC(), + }}) + if err == nil || activated { + t.Fatal("queue delivery with duplicate inbound external ID succeeded") + } + if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 1 { + t.Fatalf("queue after rolled-back inbound = %#v, want one row", queued) + } + after, listErr := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if listErr != nil || len(after) != 1 { + t.Fatalf("rollback inbound after delivery = (%#v, %v), want unchanged initialize", after, listErr) + } + reloaded, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("reload rollback Code Session: %v", err) + } + if reloaded.Status != "initializing" { + t.Fatalf("rollback Code Session status = %q, want initializing", reloaded.Status) + } +} + +func TestSessionEventQueueRejectsCrossSessionEventReference(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-event-queue-ownership-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-event-queue-ownership-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-event-queue-ownership-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + sessionA := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, sessionA.ID) + sessionB := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, sessionB.ID) + + eventA := sendSessionEvents(t, app, sessionA.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"session A"}]}]}`, defaultTestKey) + eventB := sendSessionEvents(t, app, sessionB.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"session B"}]}]}`, defaultTestKey) + eventAID := sessionEventStringField(t, eventA.Data[0], "id") + eventBID := sessionEventStringField(t, eventB.Data[0], "id") + + if _, err := app.db.Pool.Exec(ctx, ` + delete from session_event_queue + where session_event_uuid = ( + select uuid from session_events where external_id = $1 + ) + `, eventBID); err != nil { + t.Fatalf("delete Session B queue row: %v", err) + } + if _, err := app.db.Pool.Exec(ctx, ` + update session_event_queue + set session_event_uuid = ( + select uuid from session_events where external_id = $1 + ) + where session_event_uuid = ( + select uuid from session_events where external_id = $2 + ) + `, eventBID, eventAID); err != nil { + t.Fatalf("replace Session A queue event reference: %v", err) + } + + session, err := app.db.GetSession(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, sessionA.ID) + if err != nil { + t.Fatalf("load Session A: %v", err) + } + if _, err := app.db.ListSessionEventQueueItems(ctx, session); !errors.Is(err, db.ErrInvalidState) { + t.Fatalf("cross-Session queue lookup error = %v, want ErrInvalidState", err) + } +} + +func TestSessionStartupRejectsSecondUserMessage(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-conflict-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-conflict-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-startup-message-conflict-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + + first := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"first startup message"}]}]}`, defaultTestKey) + if len(first.Data) != 1 { + t.Fatalf("first startup response = %#v, want one event", first.Data) + } + firstID := sessionEventStringField(t, first.Data[0], "id") + + second := doSessionRequest( + t, + app, + http.MethodPost, + "/v1/sessions/"+session.ID+"/events?beta=true", + strings.NewReader(`{"events":[{"type":"user.message","content":[{"type":"text","text":"second startup message"}]}]}`), + defaultTestKey, + true, + ) + assertError(t, second, http.StatusConflict, "conflict_error") + + publicEvents := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey) + if len(publicEvents.Data) != 1 || !eventPageContains(publicEvents, firstID) { + t.Fatalf("public events after conflict = %#v, want only %s", publicEvents.Data, firstID) + } + queuedEventIDs := sessionEventQueueEventIDs(t, app, session.ID) + if !reflect.DeepEqual(queuedEventIDs, []string{firstID}) { + t.Fatalf("startup queue event IDs = %#v, want [%s]", queuedEventIDs, firstID) + } + + codeSessionID := launchLocalCodeSession(t, app, session.ID) + inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil { + t.Fatalf("list startup conflict inbound events: %v", err) + } + if len(inbound) != 2 || + inbound[0].EventSubtype != "initialize" || + !bytes.Contains(inbound[1].Payload, []byte("first startup message")) || + bytes.Contains(inbound[1].Payload, []byte("second startup message")) { + t.Fatalf("inbound after startup conflict = %#v, want initialize and first message", inbound) + } + if remaining := sessionEventQueueEventIDs(t, app, session.ID); len(remaining) != 0 { + t.Fatalf("startup queue after activation = %#v, want empty", remaining) + } +} + +func TestSessionStartupRejectedBatchHasNoSideEffects(t *testing.T) { + app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-outcome-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-outcome-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-startup-message-outcome-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + + response := doSessionRequest( + t, + app, + http.MethodPost, + "/v1/sessions/"+session.ID+"/events?beta=true", + strings.NewReader(`{"events":[ + {"type":"user.define_outcome","description":"must not persist","rubric":{"type":"text","text":"must pass"}}, + {"type":"user.message","content":[{"type":"text","text":"reject the whole batch"}]} + ]}`), + defaultTestKey, + true, + ) + assertError(t, response, http.StatusConflict, "conflict_error") + + retrieved := retrieveSession(t, app, session.ID, defaultTestKey) + if string(retrieved.OutcomeEvaluations) != "[]" { + t.Fatalf("outcomes after rejected startup batch = %s, want []", retrieved.OutcomeEvaluations) + } + if events := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey); len(events.Data) != 0 { + t.Fatalf("startup outcome batch public events = %#v, want empty", events.Data) + } + if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { + t.Fatalf("startup outcome batch queue = %#v, want empty", queued) + } +} + +func TestSessionTerminatedCodeSessionDoesNotReceiveRealtimeEvents(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-terminated-code-session-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-terminated-code-session-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-terminated-code-session-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + codeSessionID := launchLocalCodeSession(t, app, session.ID) + + if _, err := app.db.Pool.Exec(ctx, ` + update code_sessions + set status = 'terminated' + where external_id = $1 + `, codeSessionID); err != nil { + t.Fatalf("terminate Code Session: %v", err) + } + before, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil { + t.Fatalf("list terminated Code Session inbound before send: %v", err) + } + + sent := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"must not reach terminated code session"}]}]}`, defaultTestKey) + if len(sent.Data) != 1 { + t.Fatalf("terminated Code Session public events = %#v, want one", sent.Data) + } + after, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil { + t.Fatalf("list terminated Code Session inbound after send: %v", err) + } + if len(after) != len(before) { + t.Fatalf("terminated Code Session inbound count = %d, want unchanged %d", len(after), len(before)) + } +} + +func TestSessionStoppedBeforeCodeSessionKeepsExistingEventBehavior(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-stopped-before-code-session-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-stopped-before-code-session-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-stopped-before-code-session-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + + result, err := app.db.Pool.Exec(ctx, ` + update environment_work + set state = 'stopped', stopped_at = now(), updated_at = now() + where workspace_id = $1 + and data->>'type' = 'session' + and data->>'id' = $2 + `, getDefaultDBIDs(t, app.db).WorkspaceID, session.ID) + if err != nil { + t.Fatalf("stop Session environment work: %v", err) + } + if updated := result.RowsAffected(); updated != 1 { + t.Fatalf("stopped Session environment work rows = %d, want 1", updated) + } + + sent := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"existing stopped-session behavior"}]}]}`, defaultTestKey) + if len(sent.Data) != 1 { + t.Fatalf("stopped Session events = %#v, want one", sent.Data) + } + if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { + t.Fatalf("stopped Session startup queue = %#v, want empty", queued) + } +} + +func TestSessionStartupSerializesConcurrentUserMessages(t *testing.T) { + app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-concurrency-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-concurrency-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-startup-message-concurrency-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + + type sendResult struct { + status int + body []byte + err error + } + start := make(chan struct{}) + results := make(chan sendResult, 2) + for _, prompt := range []string{"concurrent startup message one", "concurrent startup message two"} { + request, err := http.NewRequest( + http.MethodPost, + app.baseURL+"/v1/sessions/"+session.ID+"/events?beta=true", + strings.NewReader(`{"events":[{"type":"user.message","content":[{"type":"text","text":`+quoteJSON(prompt)+`}]}]}`), + ) + if err != nil { + t.Fatalf("create concurrent startup request: %v", err) + } + request.Header.Set("X-Api-Key", defaultTestKey) + request.Header.Set("anthropic-version", "2023-06-01") + request.Header.Set("anthropic-beta", "managed-agents-2026-04-01") + request.Header.Set("Content-Type", "application/json") + go func() { + <-start + response, err := app.client.Do(request) + if err != nil { + results <- sendResult{err: err} + return + } + defer response.Body.Close() + body, readErr := io.ReadAll(response.Body) + results <- sendResult{status: response.StatusCode, body: body, err: readErr} + }() + } + close(start) + + okCount := 0 + conflictCount := 0 + for range 2 { + result := <-results + if result.err != nil { + t.Fatalf("send concurrent startup message: %v", result.err) + } + switch result.status { + case http.StatusOK: + okCount++ + case http.StatusConflict: + conflictCount++ + default: + t.Fatalf("concurrent startup status = %d, want 200 or 409: %s", result.status, result.body) + } + } + if okCount != 1 || conflictCount != 1 { + t.Fatalf("concurrent startup results = %d success, %d conflict; want 1/1", okCount, conflictCount) + } + publicEvents := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey) + queuedEventIDs := sessionEventQueueEventIDs(t, app, session.ID) + if len(publicEvents.Data) != 1 || len(queuedEventIDs) != 1 { + t.Fatalf("concurrent startup persisted public/queue = %d/%d, want 1/1", len(publicEvents.Data), len(queuedEventIDs)) + } + if eventID := sessionEventStringField(t, publicEvents.Data[0], "id"); eventID != queuedEventIDs[0] { + t.Fatalf("concurrent startup public event = %s, queue event = %s", eventID, queuedEventIDs[0]) + } +} + +func TestSessionStartupQueuesUserMessageIndependentOfEnvironmentType(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-self-hosted-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-self-hosted-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{ + "name":"sessions-startup-message-self-hosted-env", + "config":{"type":"self_hosted"} + }`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + sessionResponse := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, sessionResponse.ID) + + const prompt = "startup delivery is a Session guarantee" + sent := sendSessionEvents(t, app, sessionResponse.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"`+prompt+`"}]}]}`, defaultTestKey) + if len(sent.Data) != 1 { + t.Fatalf("self-hosted startup response = %#v, want one event", sent.Data) + } + eventID := sessionEventStringField(t, sent.Data[0], "id") + if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); !reflect.DeepEqual(queued, []string{eventID}) { + t.Fatalf("self-hosted startup queue = %#v, want [%s]", queued, eventID) + } + + ids := getDefaultDBIDs(t, app.db) + session, err := app.db.GetSession(ctx, ids.WorkspaceID, sessionResponse.ID) + if err != nil { + t.Fatalf("load self-hosted Session: %v", err) + } + environment, err := app.db.GetEnvironment(ctx, ids.WorkspaceID, env.ID) + if err != nil { + t.Fatalf("load self-hosted Environment: %v", err) + } + work, err := app.db.GetLatestEnvironmentWorkByData(ctx, ids.WorkspaceID, env.ID, "session", sessionResponse.ID) + if err != nil { + t.Fatalf("load self-hosted Session work: %v", err) + } + created, err := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil).CreateManagedAgentCodeSession( + ctx, + codesessions.ManagedAgentCreateInput{ + Session: session, + Environment: environment, + EnvironmentWork: work, + Model: "claude-opus-4-6", + WorkDir: "/home/user", + Config: json.RawMessage(`{"origin":"managed_agents_api","model":"claude-opus-4-6"}`), + }, + ) + if err != nil { + t.Fatalf("create self-hosted Code Session: %v", err) + } + + inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, created.CodeSessionID) + if err != nil { + t.Fatalf("list self-hosted startup inbound events: %v", err) + } + if len(inbound) != 2 || + inbound[0].EventSubtype != "initialize" || + inbound[1].EventType != "user" || + !bytes.Contains(inbound[1].Payload, []byte(prompt)) { + t.Fatalf("self-hosted startup inbound = %#v, want initialize and accepted user message", inbound) + } + if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 0 { + t.Fatalf("self-hosted startup queue after activation = %#v, want empty", queued) + } +} + func TestSessionClaudeCodeSubagentInternalEventsPublishToChildThread(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("sessions-claude-code-subagent-internal-bucket")) defer app.close() @@ -3277,6 +3851,7 @@ func TestSessionsSchemaHasNoForeignKeys(t *testing.T) { and ns.oid = current_schema()::regnamespace and cls.relname in ( 'sessions', 'session_threads', 'session_events', 'session_resources', + 'session_event_queue', 'code_sessions', 'code_session_inbound_events', 'code_session_outbound_events', 'code_session_internal_events' ) @@ -3481,6 +4056,38 @@ func sessionEventStringField(t *testing.T, raw json.RawMessage, field string) st return value } +func sessionEventQueueEventIDs(t *testing.T, app *testApp, sessionID string) []string { + t.Helper() + rows, err := app.db.Pool.Query(context.Background(), ` + select e.external_id + from session_event_queue q + join sessions s + on s.uuid = q.session_uuid + and s.workspace_id = q.workspace_id + join session_events e + on e.uuid = q.session_event_uuid + and e.workspace_id = q.workspace_id + where s.external_id = $1 + order by q.id asc + `, sessionID) + if err != nil { + t.Fatalf("list session event queue: %v", err) + } + defer rows.Close() + var eventIDs []string + for rows.Next() { + var eventID string + if err := rows.Scan(&eventID); err != nil { + t.Fatalf("scan session event queue: %v", err) + } + eventIDs = append(eventIDs, eventID) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate session event queue: %v", err) + } + return eventIDs +} + func eventPageContains(events sessionEventPageAPIResponse, needle string) bool { for _, event := range events.Data { if bytes.Contains(event, []byte(needle)) { From 2151cf72889dc657add18604ac1bd95444214710 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Thu, 30 Jul 2026 23:53:12 +0800 Subject: [PATCH 02/23] test(sessions): keep core startup delivery coverage only Drop overlapping startup-window cases (self-hosted, terminated CS, stopped work, cross-session queue ownership) and fold post-activation realtime assertions into the atomic activation test. Slim the #189 runner and deployment multi-message checks without losing the unique paths. --- tests/deployments_api_test.go | 35 +--- tests/environments_runner_cloud_test.go | 26 +-- tests/sessions_api_test.go | 236 +----------------------- 3 files changed, 16 insertions(+), 281 deletions(-) diff --git a/tests/deployments_api_test.go b/tests/deployments_api_test.go index dfe4eb41..b9b2c76a 100644 --- a/tests/deployments_api_test.go +++ b/tests/deployments_api_test.go @@ -262,39 +262,8 @@ func TestDeploymentsAPI(t *testing.T) { } defer deleteSession(t, app, *run.SessionID) - rows, err := app.db.Pool.Query(context.Background(), ` - select e.payload #>> '{content,0,text}' - from session_event_queue q - join sessions s - on s.uuid = q.session_uuid - and s.workspace_id = q.workspace_id - join session_events e - on e.uuid = q.session_event_uuid - and e.workspace_id = q.workspace_id - where s.external_id = $1 - order by q.id asc - `, *run.SessionID) - if err != nil { - t.Fatalf("list deployment startup queue: %v", err) - } - var queuedPrompts []string - for rows.Next() { - var prompt string - if err := rows.Scan(&prompt); err != nil { - rows.Close() - t.Fatalf("scan deployment startup queue: %v", err) - } - queuedPrompts = append(queuedPrompts, prompt) - } - if err := rows.Err(); err != nil { - rows.Close() - t.Fatalf("iterate deployment startup queue: %v", err) - } - rows.Close() - if len(queuedPrompts) != 2 || - queuedPrompts[0] != "deployment first" || - queuedPrompts[1] != "deployment second" { - t.Fatalf("deployment startup queue = %#v, want first then second", queuedPrompts) + if queued := sessionEventQueueEventIDs(t, app, *run.SessionID); len(queued) != 2 { + t.Fatalf("deployment startup queue size = %d, want 2", len(queued)) } codeSessionID := launchLocalCodeSession(t, app, *run.SessionID) diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index 2e2ba350..e54f7d3b 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -1,6 +1,7 @@ package tests import ( + "bytes" "context" "encoding/json" "errors" @@ -346,7 +347,6 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te provider := &recordingRunnerProvider{ sandboxID: "sandbox-runner-startup-message", beforeCreate: func() { - // 发送时 Code Session 尚不存在,无法走实时入队。 if _, err := app.db.GetCodeSessionBySessionExternalID(ctx, ids.WorkspaceID, session.ID); !errors.Is(err, db.ErrNotFound) { t.Fatalf("code session at provider.Create = %v, want ErrNotFound", err) } @@ -355,9 +355,6 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te t.Fatalf("accepted events = %#v, want one", sent.Data) } acceptedEventID = sessionEventStringField(t, sent.Data[0], "id") - if queued := sessionEventQueueEventIDs(t, app, session.ID); !reflect.DeepEqual(queued, []string{acceptedEventID}) { - t.Fatalf("startup session event queue = %#v, want [%s]", queued, acceptedEventID) - } }, } @@ -366,13 +363,10 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te t.Fatalf("RunOnce() = (%t, %v), want success", processed, err) } - // 公共表应已接收该消息。 publicEvents := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey) if !eventPageContains(publicEvents, acceptedEventID) { t.Fatalf("session_events missing accepted event %q: %+v", acceptedEventID, publicEvents.Data) } - - // worker 入站队列也应包含同一条用户消息,而不能只有 initialize。 codeSession, err := app.db.GetCodeSessionBySessionExternalID(ctx, ids.WorkspaceID, session.ID) if err != nil { t.Fatalf("load Code Session after runner startup: %v", err) @@ -383,20 +377,10 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te } if len(queued) != 2 || queued[0].EventSubtype != "initialize" || - queued[1].EventType != "user" { - t.Fatalf("inbound = %#v, want initialize then user (public accepted, inbound incomplete)", queued) - } - var payload struct { - UUID string `json:"uuid"` - Message struct { - Content string `json:"content"` - } `json:"message"` - } - if err := json.Unmarshal(queued[1].Payload, &payload); err != nil { - t.Fatalf("decode queued user message: %v", err) - } - if payload.UUID != acceptedEventID || payload.Message.Content != prompt { - t.Fatalf("queued user = %#v, want uuid=%q content=%q", payload, acceptedEventID, prompt) + queued[1].EventType != "user" || + !bytes.Contains(queued[1].Payload, []byte(acceptedEventID)) || + !bytes.Contains(queued[1].Payload, []byte(prompt)) { + t.Fatalf("inbound = %#v, want initialize then accepted user message", queued) } if remaining := sessionEventQueueEventIDs(t, app, session.ID); len(remaining) != 0 { t.Fatalf("remaining startup session event queue = %#v, want empty", remaining) diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 74244462..2e411c90 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -687,12 +687,12 @@ func TestSessionClaudeCodeTaskEventsMapToCanonicalThreads(t *testing.T) { func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) { ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-activation-cutover-bucket")) + app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-activation-bucket")) defer app.close() - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-managed-agent-activation-cutover-agent"}`) + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-managed-agent-activation-agent"}`) defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-managed-agent-activation-cutover-env"}`) + env := createEnvironment(t, app, `{"name":"sessions-managed-agent-activation-env"}`) defer cleanupEnvironmentRows(t, app.db, env.ID) session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) defer deleteSession(t, app, session.ID) @@ -783,37 +783,8 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) if len(inbound) != 2 || !bytes.Contains(inbound[1].Payload, []byte(acceptedEventID)) { t.Fatalf("inbound after activation = %#v, want initialize and accepted user message", inbound) } -} - -func TestManagedAgentActivationCutoverRoutesFollowingBatchRealtime(t *testing.T) { - ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-activation-realtime-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-managed-agent-activation-realtime-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-managed-agent-activation-realtime-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, session.ID) - codeSessionID := launchLocalCodeSession(t, app, session.ID) - - if _, err := app.db.Pool.Exec(ctx, ` - update code_sessions - set status = 'initializing' - where external_id = $1 - `, codeSessionID); err != nil { - t.Fatalf("set cutover code session initializing: %v", err) - } - codeSession, err := app.db.GetCodeSession(ctx, codeSessionID) - if err != nil { - t.Fatalf("load cutover code session: %v", err) - } - activated, err := app.db.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, nil, nil) - if err != nil || !activated { - t.Fatalf("activate empty startup queue = (%t, %v), want success", activated, err) - } + // active 后只实时投当前 batch,不再写入 startup queue。 sent := sendSessionEvents(t, app, session.ID, `{"events":[ {"type":"user.message","content":[{"type":"text","text":"post-cutover batch one"}]}, {"type":"user.message","content":[{"type":"text","text":"post-cutover batch two"}]} @@ -824,14 +795,14 @@ func TestManagedAgentActivationCutoverRoutesFollowingBatchRealtime(t *testing.T) if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { t.Fatalf("post-cutover startup queue = %#v, want empty", queued) } - inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + inbound, err = app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) if err != nil { t.Fatalf("list post-cutover inbound: %v", err) } - if len(inbound) != 3 || - !bytes.Contains(inbound[1].Payload, []byte("post-cutover batch one")) || - !bytes.Contains(inbound[2].Payload, []byte("post-cutover batch two")) { - t.Fatalf("post-cutover inbound = %#v, want realtime user batch", inbound) + if len(inbound) != 4 || + !bytes.Contains(inbound[2].Payload, []byte("post-cutover batch one")) || + !bytes.Contains(inbound[3].Payload, []byte("post-cutover batch two")) { + t.Fatalf("post-cutover inbound = %#v, want realtime user batch after startup message", inbound) } } @@ -905,54 +876,6 @@ func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { } } -func TestSessionEventQueueRejectsCrossSessionEventReference(t *testing.T) { - ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-event-queue-ownership-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-event-queue-ownership-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-event-queue-ownership-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - sessionA := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, sessionA.ID) - sessionB := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, sessionB.ID) - - eventA := sendSessionEvents(t, app, sessionA.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"session A"}]}]}`, defaultTestKey) - eventB := sendSessionEvents(t, app, sessionB.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"session B"}]}]}`, defaultTestKey) - eventAID := sessionEventStringField(t, eventA.Data[0], "id") - eventBID := sessionEventStringField(t, eventB.Data[0], "id") - - if _, err := app.db.Pool.Exec(ctx, ` - delete from session_event_queue - where session_event_uuid = ( - select uuid from session_events where external_id = $1 - ) - `, eventBID); err != nil { - t.Fatalf("delete Session B queue row: %v", err) - } - if _, err := app.db.Pool.Exec(ctx, ` - update session_event_queue - set session_event_uuid = ( - select uuid from session_events where external_id = $1 - ) - where session_event_uuid = ( - select uuid from session_events where external_id = $2 - ) - `, eventBID, eventAID); err != nil { - t.Fatalf("replace Session A queue event reference: %v", err) - } - - session, err := app.db.GetSession(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, sessionA.ID) - if err != nil { - t.Fatalf("load Session A: %v", err) - } - if _, err := app.db.ListSessionEventQueueItems(ctx, session); !errors.Is(err, db.ErrInvalidState) { - t.Fatalf("cross-Session queue lookup error = %v, want ErrInvalidState", err) - } -} - func TestSessionStartupRejectsSecondUserMessage(t *testing.T) { ctx := context.Background() app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-conflict-bucket")) @@ -1044,79 +967,6 @@ func TestSessionStartupRejectedBatchHasNoSideEffects(t *testing.T) { } } -func TestSessionTerminatedCodeSessionDoesNotReceiveRealtimeEvents(t *testing.T) { - ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-terminated-code-session-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-terminated-code-session-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-terminated-code-session-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, session.ID) - codeSessionID := launchLocalCodeSession(t, app, session.ID) - - if _, err := app.db.Pool.Exec(ctx, ` - update code_sessions - set status = 'terminated' - where external_id = $1 - `, codeSessionID); err != nil { - t.Fatalf("terminate Code Session: %v", err) - } - before, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) - if err != nil { - t.Fatalf("list terminated Code Session inbound before send: %v", err) - } - - sent := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"must not reach terminated code session"}]}]}`, defaultTestKey) - if len(sent.Data) != 1 { - t.Fatalf("terminated Code Session public events = %#v, want one", sent.Data) - } - after, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) - if err != nil { - t.Fatalf("list terminated Code Session inbound after send: %v", err) - } - if len(after) != len(before) { - t.Fatalf("terminated Code Session inbound count = %d, want unchanged %d", len(after), len(before)) - } -} - -func TestSessionStoppedBeforeCodeSessionKeepsExistingEventBehavior(t *testing.T) { - ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-stopped-before-code-session-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-stopped-before-code-session-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-stopped-before-code-session-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, session.ID) - - result, err := app.db.Pool.Exec(ctx, ` - update environment_work - set state = 'stopped', stopped_at = now(), updated_at = now() - where workspace_id = $1 - and data->>'type' = 'session' - and data->>'id' = $2 - `, getDefaultDBIDs(t, app.db).WorkspaceID, session.ID) - if err != nil { - t.Fatalf("stop Session environment work: %v", err) - } - if updated := result.RowsAffected(); updated != 1 { - t.Fatalf("stopped Session environment work rows = %d, want 1", updated) - } - - sent := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"existing stopped-session behavior"}]}]}`, defaultTestKey) - if len(sent.Data) != 1 { - t.Fatalf("stopped Session events = %#v, want one", sent.Data) - } - if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { - t.Fatalf("stopped Session startup queue = %#v, want empty", queued) - } -} - func TestSessionStartupSerializesConcurrentUserMessages(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-concurrency-bucket")) defer app.close() @@ -1191,74 +1041,6 @@ func TestSessionStartupSerializesConcurrentUserMessages(t *testing.T) { } } -func TestSessionStartupQueuesUserMessageIndependentOfEnvironmentType(t *testing.T) { - ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-self-hosted-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-self-hosted-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{ - "name":"sessions-startup-message-self-hosted-env", - "config":{"type":"self_hosted"} - }`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - sessionResponse := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, sessionResponse.ID) - - const prompt = "startup delivery is a Session guarantee" - sent := sendSessionEvents(t, app, sessionResponse.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"`+prompt+`"}]}]}`, defaultTestKey) - if len(sent.Data) != 1 { - t.Fatalf("self-hosted startup response = %#v, want one event", sent.Data) - } - eventID := sessionEventStringField(t, sent.Data[0], "id") - if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); !reflect.DeepEqual(queued, []string{eventID}) { - t.Fatalf("self-hosted startup queue = %#v, want [%s]", queued, eventID) - } - - ids := getDefaultDBIDs(t, app.db) - session, err := app.db.GetSession(ctx, ids.WorkspaceID, sessionResponse.ID) - if err != nil { - t.Fatalf("load self-hosted Session: %v", err) - } - environment, err := app.db.GetEnvironment(ctx, ids.WorkspaceID, env.ID) - if err != nil { - t.Fatalf("load self-hosted Environment: %v", err) - } - work, err := app.db.GetLatestEnvironmentWorkByData(ctx, ids.WorkspaceID, env.ID, "session", sessionResponse.ID) - if err != nil { - t.Fatalf("load self-hosted Session work: %v", err) - } - created, err := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil).CreateManagedAgentCodeSession( - ctx, - codesessions.ManagedAgentCreateInput{ - Session: session, - Environment: environment, - EnvironmentWork: work, - Model: "claude-opus-4-6", - WorkDir: "/home/user", - Config: json.RawMessage(`{"origin":"managed_agents_api","model":"claude-opus-4-6"}`), - }, - ) - if err != nil { - t.Fatalf("create self-hosted Code Session: %v", err) - } - - inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, created.CodeSessionID) - if err != nil { - t.Fatalf("list self-hosted startup inbound events: %v", err) - } - if len(inbound) != 2 || - inbound[0].EventSubtype != "initialize" || - inbound[1].EventType != "user" || - !bytes.Contains(inbound[1].Payload, []byte(prompt)) { - t.Fatalf("self-hosted startup inbound = %#v, want initialize and accepted user message", inbound) - } - if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 0 { - t.Fatalf("self-hosted startup queue after activation = %#v, want empty", queued) - } -} - func TestSessionClaudeCodeSubagentInternalEventsPublishToChildThread(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("sessions-claude-code-subagent-internal-bucket")) defer app.close() From 4029d818a05c9ab39ca8bf6dc37ddbce83b12092 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 00:02:07 +0800 Subject: [PATCH 03/23] refactor(sessions): simplify startup queue handoff after review Use samber/lo for small event filters, align the conflict sentinel with other DB errors, and keep activation checks to type match plus queue snapshot comparison. --- .../be/session-startup-message-delivery.md | 2 +- .../managed_agent_code_session.go | 21 ++++++++------ internal/db/code_sessions.go | 17 +++++------ internal/db/db.go | 29 +++++++++---------- internal/db/session_event_queue.go | 17 +++++------ 5 files changed, 41 insertions(+), 45 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index e97235b1..feba64f8 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -264,7 +264,7 @@ sequenceDiagram ```text 锁 Session → 锁 initializing Code Session -→ 校验每个 queue item 的 Session ownership 和 user.message 类型 +→ 校验 queue item 均为 user.message(Session ownership 已由 List 保证) → 锁当前完整 queue → 将当前 queue 与事务外快照逐项比较 → 按 queue.id 顺序写入全部 inbound diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 120658ab..f5859b43 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "time" + "github.com/samber/lo" "github.com/superduck-ai/open-managed-agents/internal/auth" "github.com/superduck-ai/open-managed-agents/internal/db" "github.com/superduck-ai/open-managed-agents/internal/ids" @@ -137,10 +139,12 @@ func (s *Service) activateManagedAgentCodeSession( if err != nil { return err } - inputs := make([]db.AppendCodeSessionEventInput, 0, len(items)) - for _, item := range items { + inputs, err := lo.MapErr(items, func(item db.SessionEventQueueItem, _ int) (db.AppendCodeSessionEventInput, error) { if item.Event.EventType != "user.message" { - return errors.New("session event queue contains a non-user message") + return db.AppendCodeSessionEventInput{}, fmt.Errorf( + "%w: session event queue contains a non-user message", + db.ErrInvalidState, + ) } payload, err := workerPayloadForPublicEvent( codeSession.ExternalID, @@ -148,13 +152,12 @@ func (s *Service) activateManagedAgentCodeSession( item.Event.ProcessedAt, ) if err != nil { - return err + return db.AppendCodeSessionEventInput{}, err } - inbound, err := newInboundEventInput(codeSession.ExternalID, payload, "public-session") - if err != nil { - return err - } - inputs = append(inputs, inbound) + return newInboundEventInput(codeSession.ExternalID, payload, "public-session") + }) + if err != nil { + return err } activated, err := s.db.ActivateManagedAgentCodeSessionWithQueue( ctx, diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 57fec8f3..6ff1d1ec 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -10,6 +10,7 @@ import ( "time" "github.com/jmoiron/sqlx" + "github.com/samber/lo" ) type CodeSession struct { @@ -330,16 +331,12 @@ func (d *DB) ActivateManagedAgentCodeSessionWithQueue( if err != nil { return false, err } - for _, item := range items { - if item.sessionUUID != session.UUID || - item.Event.UUID != item.sessionEventUUID || - item.Event.OrganizationID != session.OrganizationID || - item.Event.WorkspaceID != session.WorkspaceID || - item.Event.SessionID != session.ID || - item.Event.SessionExternalID != session.ExternalID || - item.Event.EventType != "user.message" { - return false, ErrInvalidState - } + // Ownership is enforced by ListSessionEventQueueItems; activation only + // re-checks event type and that the locked queue still matches the snapshot. + if !lo.EveryBy(items, func(item SessionEventQueueItem) bool { + return item.Event.EventType == "user.message" + }) { + return false, ErrInvalidState } queueRows, err := listSessionEventQueueIdentityRows(ctx, tx, session, true) if err != nil { diff --git a/internal/db/db.go b/internal/db/db.go index 8ba2f2e9..14f42161 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -20,23 +20,22 @@ import ( ) var ( - ErrNotFound = platform.ErrNotFound - ErrInvalidState = errors.New("invalid state") - ErrPreconditionFailed = errors.New("precondition failed") - ErrDuplicate = errors.New("duplicate") - ErrVersionConflict = errors.New("version conflict") - ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") - ErrWorkerNotRegistered = errors.New("worker not registered") - ErrWorkerLeaseExpired = errors.New("worker lease expired") - ErrStorageLimitExceeded = errors.New("storage limit exceeded") - ErrStorageUsageUnderflow = errors.New("storage usage underflow") - ErrLimitExceeded = errors.New("limit exceeded") - ErrFileInUse = errors.New("file is in use") - ErrFileReferenceNotFound = errors.New("file reference not found") + ErrNotFound = platform.ErrNotFound + ErrInvalidState = errors.New("invalid state") + ErrPreconditionFailed = errors.New("precondition failed") + ErrDuplicate = errors.New("duplicate") + ErrVersionConflict = errors.New("version conflict") + ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") + ErrWorkerNotRegistered = errors.New("worker not registered") + ErrWorkerLeaseExpired = errors.New("worker lease expired") + ErrStorageLimitExceeded = errors.New("storage limit exceeded") + ErrStorageUsageUnderflow = errors.New("storage usage underflow") + ErrLimitExceeded = errors.New("limit exceeded") + ErrFileInUse = errors.New("file is in use") + ErrFileReferenceNotFound = errors.New("file reference not found") + ErrSessionStartupMessageConflict = errors.New("session startup message conflict") ) -var ErrSessionStartupMessageConflict = errors.New("session startup message conflict") - type DB struct { Pool *pgxpool.Pool sql *sqlx.DB diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 2f0a6f4d..7bdbd5f9 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/jmoiron/sqlx" + "github.com/samber/lo" ) type SessionEventDelivery string @@ -61,12 +62,9 @@ func (d *DB) AppendSessionEventsForDelivery( if session.ArchivedAt != nil { return nil, "", ErrInvalidState } - userMessageCount := 0 - for _, event := range events { - if event.EventType == "user.message" { - userMessageCount++ - } - } + userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { + return event.EventType == "user.message" + }) startup := false if userMessageCount > 0 { startup, err = sessionUserMessageStartupWindowSQLX(ctx, tx, session) @@ -291,10 +289,9 @@ func enqueueSessionEventsSQLXTx( session Session, events []SessionEvent, ) error { - for _, event := range events { - if event.EventType != "user.message" { - continue - } + for _, event := range lo.Filter(events, func(event SessionEvent, _ int) bool { + return event.EventType == "user.message" + }) { if _, err := namedExecContext(ctx, tx, ` insert into session_event_queue ( organization_id, workspace_id, session_uuid, session_event_uuid From 112e515dd01d0433a5236aecb220cc79f55a2bcd Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 00:12:54 +0800 Subject: [PATCH 04/23] fix(sessions): only queue startup messages for cloud environments Align the startup window with Runner managed Code Session activation so self_hosted sessions keep accepting events without an undrained queue. --- .../be/session-startup-message-delivery.md | 6 +++-- internal/db/session_event_queue.go | 27 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index feba64f8..a8c84e1e 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -159,7 +159,9 @@ queue 或激活 Code Session。 2. 如果 Code Session 存在且状态不是 `initializing`,不进入 startup queue; 3. 如果 Code Session 不存在或仍为 `initializing`,检查对应 Environment Work; 4. work data 必须指向当前 Session,且状态为 `queued`、`starting` 或 `active`; -5. Environment 类型不参与判断; +5. Environment 必须是 cloud(`config.type = cloud`),与 Runner 只对 cloud 创建 + managed Code Session 并激活 queue 的路径一致;`self_hosted` 等非 cloud 环境不进 + startup queue,避免无人 drain 时首条消息挂死、后续消息 409; 6. work 已停止或最新 Code Session 是其他状态时,保持既有事件行为。 这个判断必须在 Send 事务锁住 Session 行之后执行,不能在 API 层提前查询。发送和激活只有 @@ -405,4 +407,4 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | Send 事务先于激活获得 Session 锁 | 消息进入 queue,激活发现变化后重试 | | Code Session 已 active | 不写 startup queue,只实时投当前 batch | | Code Session 非 active | 不实时写 inbound | -| Environment 类型不同 | 不影响 Session 启动窗口判定 | +| Environment 为 self_hosted 等非 cloud | 不进 startup queue;消息走既有 realtime/no-op 路径,不 409 | diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 7bdbd5f9..653f8692 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -234,19 +234,28 @@ func sessionUserMessageStartupWindowSQLX( return false, err } + // Only cloud environments run managed Code Session activation that drains + // the queue (see Runner.prepareManagedAgentLaunch / cloudEnvironment). var startupWorkExists bool err = namedGetContext(ctx, database, &startupWorkExists, ` select exists ( select 1 - from environment_work - where organization_id = :organization_id - and workspace_id = :workspace_id - and environment_id = :environment_id - and environment_external_id = :environment_external_id - and data->>'type' = 'session' - and data->>'id' = :session_external_id - and state in ('queued', 'starting', 'active') - and deleted_at is null + from environment_work ew + join environments e + on e.id = ew.environment_id + and e.organization_id = ew.organization_id + and e.workspace_id = ew.workspace_id + and e.external_id = ew.environment_external_id + where ew.organization_id = :organization_id + and ew.workspace_id = :workspace_id + and ew.environment_id = :environment_id + and ew.environment_external_id = :environment_external_id + and ew.data->>'type' = 'session' + and ew.data->>'id' = :session_external_id + and ew.state in ('queued', 'starting', 'active') + and ew.deleted_at is null + and e.deleted_at is null + and e.config->>'type' = 'cloud' ) `, map[string]any{ "organization_id": session.OrganizationID, From 6c573f53447d057486d0ea85d8f76f40df4391c6 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 08:38:44 +0800 Subject: [PATCH 05/23] fix(db): use stable tenant UUIDs for startup queue Amp-Thread-ID: https://ampcode.com/threads/T-019fb1cb-d867-7480-91fa-b9d947642e90 Co-authored-by: Amp --- .../be/session-startup-message-delivery.md | 6 +- internal/db/code_sessions.go | 12 +- ..._session_event_queue_tenant_references.sql | 106 ++++++++++++++++++ internal/db/session_event_queue.go | 49 +++++--- internal/db/sessions_sqlx.go | 12 +- tests/sessions_api_test.go | 10 +- 6 files changed, 165 insertions(+), 30 deletions(-) create mode 100644 internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index a8c84e1e..e602b5df 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -136,14 +136,16 @@ sequenceDiagram `session_event_queue` 是临时责任表,不是 payload 副本。它只保存: - queue 自身顺序 `id`; +- `organization_uuid`; +- `workspace_uuid`; - `session_uuid`; - `session_event_uuid`; -- organization/workspace 作用域; - 创建时间。 表中没有 payload、delivery status、重试次数或 delivered history。事件内容始终从 `session_events` 读取,避免形成第二份事件事实源。`session_event_uuid` 唯一,防止同一公开 -事件重复获得两条 queue 责任。 +事件重复获得两条 queue 责任。organization、workspace、Session 和 event 均使用稳定 UUID +引用,避免租户迁移、部分导入或跨库合并时 identity 重映射导致 queue row 失去归属。 读取 queue 引用时必须同时匹配 organization、workspace、Session ID、Session external ID 和 event UUID。queue 如果指向其他 Session 的事件,创建流程直接失败,不得写 inbound、删 diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 6ff1d1ec..37a42e48 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -359,10 +359,14 @@ func (d *DB) ActivateManagedAgentCodeSessionWithQueue( } } deletedResult, err := namedExecContext(ctx, tx, ` - delete from session_event_queue - where organization_id = :organization_id - and workspace_id = :workspace_id - and session_uuid = CAST(:session_uuid AS uuid) + delete from session_event_queue q + using organizations o, workspaces w + where q.organization_uuid = o.uuid + and q.workspace_uuid = w.uuid + and w.organization_id = o.id + and o.id = :organization_id + and w.id = :workspace_id + and q.session_uuid = CAST(:session_uuid AS uuid) `, map[string]any{ "organization_id": session.OrganizationID, "workspace_id": session.WorkspaceID, diff --git a/internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql b/internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql new file mode 100644 index 00000000..476fa80d --- /dev/null +++ b/internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql @@ -0,0 +1,106 @@ +-- +goose Up + +-- Queue ownership must survive tenant identity remapping during data moves. +create table session_event_queue_uuid_refs ( + id bigint generated always as identity, + uuid uuid not null default gen_random_uuid(), + organization_uuid uuid not null, + workspace_uuid uuid not null, + session_uuid uuid not null, + session_event_uuid uuid not null, + created_at timestamptz not null default now(), + constraint session_event_queue_uuid_refs_id_pk primary key (id), + constraint session_event_queue_uuid_refs_uuid_key unique (uuid), + constraint session_event_queue_uuid_refs_event_uuid_key unique (session_event_uuid) +); + +insert into session_event_queue_uuid_refs ( + id, uuid, organization_uuid, workspace_uuid, session_uuid, session_event_uuid, created_at +) +overriding system value +select q.id, q.uuid, o.uuid, w.uuid, q.session_uuid, q.session_event_uuid, q.created_at +from session_event_queue q +join organizations o on o.id = q.organization_id +join workspaces w on w.id = q.workspace_id and w.organization_id = o.id +order by q.id; + +-- +goose StatementBegin +do $$ +begin + if (select count(*) from session_event_queue_uuid_refs) <> + (select count(*) from session_event_queue) then + raise exception 'cannot migrate session event queue tenant references to UUID'; + end if; +end $$; +-- +goose StatementEnd + +select setval( + pg_get_serial_sequence('session_event_queue_uuid_refs', 'id'), + coalesce((select max(id) from session_event_queue_uuid_refs), 1), + exists (select 1 from session_event_queue_uuid_refs) +); + +drop table session_event_queue; +alter table session_event_queue_uuid_refs rename to session_event_queue; +alter table session_event_queue rename constraint session_event_queue_uuid_refs_id_pk + to session_event_queue_id_pk; +alter table session_event_queue rename constraint session_event_queue_uuid_refs_uuid_key + to session_event_queue_uuid_key; +alter table session_event_queue rename constraint session_event_queue_uuid_refs_event_uuid_key + to session_event_queue_session_event_uuid_key; + +create index session_event_queue_session_order_v2_idx + on session_event_queue (organization_uuid, workspace_uuid, session_uuid, id asc); + +-- +goose Down + +create table session_event_queue_identity_refs ( + id bigint generated always as identity, + uuid uuid not null default gen_random_uuid(), + organization_id bigint not null, + workspace_id bigint not null, + session_uuid uuid not null, + session_event_uuid uuid not null, + created_at timestamptz not null default now(), + constraint session_event_queue_identity_refs_id_pk primary key (id), + constraint session_event_queue_identity_refs_uuid_key unique (uuid), + constraint session_event_queue_identity_refs_event_uuid_key unique (session_event_uuid) +); + +insert into session_event_queue_identity_refs ( + id, uuid, organization_id, workspace_id, session_uuid, session_event_uuid, created_at +) +overriding system value +select q.id, q.uuid, o.id, w.id, q.session_uuid, q.session_event_uuid, q.created_at +from session_event_queue q +join organizations o on o.uuid = q.organization_uuid +join workspaces w on w.uuid = q.workspace_uuid and w.organization_id = o.id +order by q.id; + +-- +goose StatementBegin +do $$ +begin + if (select count(*) from session_event_queue_identity_refs) <> + (select count(*) from session_event_queue) then + raise exception 'cannot restore session event queue tenant identity references'; + end if; +end $$; +-- +goose StatementEnd + +select setval( + pg_get_serial_sequence('session_event_queue_identity_refs', 'id'), + coalesce((select max(id) from session_event_queue_identity_refs), 1), + exists (select 1 from session_event_queue_identity_refs) +); + +drop table session_event_queue; +alter table session_event_queue_identity_refs rename to session_event_queue; +alter table session_event_queue rename constraint session_event_queue_identity_refs_id_pk + to session_event_queue_id_pk; +alter table session_event_queue rename constraint session_event_queue_identity_refs_uuid_key + to session_event_queue_uuid_key; +alter table session_event_queue rename constraint session_event_queue_identity_refs_event_uuid_key + to session_event_queue_session_event_uuid_key; + +create index session_event_queue_session_order_v1_idx + on session_event_queue (organization_id, workspace_id, session_uuid, id asc); diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 653f8692..d028f4eb 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -167,16 +167,18 @@ func listSessionEventQueueIdentityRows( lock bool, ) ([]sessionEventQueueIdentityRow, error) { query := ` - select id, CAST(session_uuid AS text) as session_uuid, - CAST(session_event_uuid AS text) as session_event_uuid - from session_event_queue - where organization_id = :organization_id - and workspace_id = :workspace_id - and session_uuid = CAST(:session_uuid AS uuid) - order by id asc + select q.id, CAST(q.session_uuid AS text) as session_uuid, + CAST(q.session_event_uuid AS text) as session_event_uuid + from session_event_queue q + join organizations o on o.uuid = q.organization_uuid + join workspaces w on w.uuid = q.workspace_uuid and w.organization_id = o.id + where o.id = :organization_id + and w.id = :workspace_id + and q.session_uuid = CAST(:session_uuid AS uuid) + order by q.id asc ` if lock { - query += ` for update` + query += ` for update of q` } var rows []sessionEventQueueIdentityRow err := namedSelectContext(ctx, database, &rows, query, map[string]any{ @@ -279,10 +281,12 @@ func sessionEventQueueExistsSQLX( err := namedGetContext(ctx, database, &exists, ` select exists ( select 1 - from session_event_queue - where organization_id = :organization_id - and workspace_id = :workspace_id - and session_uuid = CAST(:session_uuid AS uuid) + from session_event_queue q + join organizations o on o.uuid = q.organization_uuid + join workspaces w on w.uuid = q.workspace_uuid and w.organization_id = o.id + where o.id = :organization_id + and w.id = :workspace_id + and q.session_uuid = CAST(:session_uuid AS uuid) ) `, map[string]any{ "organization_id": session.OrganizationID, @@ -301,22 +305,31 @@ func enqueueSessionEventsSQLXTx( for _, event := range lo.Filter(events, func(event SessionEvent, _ int) bool { return event.EventType == "user.message" }) { - if _, err := namedExecContext(ctx, tx, ` + result, err := namedExecContext(ctx, tx, ` insert into session_event_queue ( - organization_id, workspace_id, session_uuid, session_event_uuid + organization_uuid, workspace_uuid, session_uuid, session_event_uuid ) - values ( - :organization_id, :workspace_id, + select o.uuid, w.uuid, CAST(:session_uuid AS uuid), CAST(:session_event_uuid AS uuid) - ) + from organizations o + join workspaces w on w.organization_id = o.id + where o.id = :organization_id and w.id = :workspace_id `, map[string]any{ "organization_id": session.OrganizationID, "workspace_id": session.WorkspaceID, "session_uuid": session.UUID, "session_event_uuid": event.UUID, - }); err != nil { + }) + if err != nil { + return err + } + inserted, err := result.RowsAffected() + if err != nil { return err } + if inserted != 1 { + return ErrInvalidState + } } return nil } diff --git a/internal/db/sessions_sqlx.go b/internal/db/sessions_sqlx.go index 6f5420d3..a1791aa3 100644 --- a/internal/db/sessions_sqlx.go +++ b/internal/db/sessions_sqlx.go @@ -125,10 +125,14 @@ const ( and deleted_at is null ` deleteSessionEventQueueQuery = ` - delete from session_event_queue - where organization_id = :organization_id - and workspace_id = :workspace_id - and session_uuid = CAST(:session_uuid AS uuid) + delete from session_event_queue q + using organizations o, workspaces w + where q.organization_uuid = o.uuid + and q.workspace_uuid = w.uuid + and w.organization_id = o.id + and o.id = :organization_id + and w.id = :workspace_id + and q.session_uuid = CAST(:session_uuid AS uuid) ` stopDeletedSessionEnvironmentWorkQuery = ` update environment_work diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 2e411c90..2348932b 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -3843,12 +3843,18 @@ func sessionEventQueueEventIDs(t *testing.T, app *testApp, sessionID string) []s rows, err := app.db.Pool.Query(context.Background(), ` select e.external_id from session_event_queue q + join organizations o on o.uuid = q.organization_uuid + join workspaces w + on w.uuid = q.workspace_uuid + and w.organization_id = o.id join sessions s on s.uuid = q.session_uuid - and s.workspace_id = q.workspace_id + and s.organization_id = o.id + and s.workspace_id = w.id join session_events e on e.uuid = q.session_event_uuid - and e.workspace_id = q.workspace_id + and e.organization_id = o.id + and e.workspace_id = w.id where s.external_id = $1 order by q.id asc `, sessionID) From 73b2d10dcf43e40bb362c56823a3ad365e7d45fe Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 09:19:01 +0800 Subject: [PATCH 06/23] fix(sessions): reinject session history when activating code sessions Merge historical session_events with the startup queue on activation so new sandboxes keep prior turns while still delivering startup-window messages reliably. --- .../be/session-startup-message-delivery.md | 35 +++++--- .../managed_agent_code_session.go | 88 ++++++++++++++++--- internal/db/code_sessions.go | 10 +-- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index e602b5df..272d0b7f 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -26,18 +26,19 @@ Code Session 激活事务中与 queue 清理一起原子写入 inbound,保证 - 不增加 `starting` 等公开 Session 状态,客户端仍看到现有 `idle`; - 不阻止客户端在 Session 创建后调用 Send Events; -- 不扫描完整 `session_events`,不维护 watermark; +- 不在 Send Events 路径扫描或重放历史;历史注入仅发生在 Code Session 激活; +- 不维护跨激活的 watermark / 通用 outbox; - 不在 worker register 或 heartbeat 中补投; -- 不把 queue 扩展成永久投递历史或通用 outbox; +- 不把 queue 扩展成永久投递历史; - 不改变 active 路径的事件转换、tool confirmation 和 batch 范围。 ## 修改前后差异 | 关注点 | 修改前 | 修改后 | | --- | --- | --- | -| 启动期事件来源 | Runner prepare 时读取一次 `InitialEvents` 快照 | Send Events 提交时记录明确的 queue 引用 | +| 启动期增量来源 | Runner prepare 时读取一次含历史的 `InitialEvents` 快照 | Send 启动窗写入 queue;激活时再合并历史 `session_events` | | prepare 后到达的消息 | 不在旧快照中 | 与 `session_events` 同事务进入 queue | -| Code Session 创建 | 使用 prepare 阶段快照写 inbound | 只读取 `session_event_queue` 指向的事件 | +| Code Session 创建 | 使用 prepare 阶段快照写 inbound | 激活时:历史 `session_events` + queue → inbound | | 消息责任 | API 200 后没有跨启动流程的持久化责任 | queue row 持有到最终激活事务提交 | | 激活条件 | 快照处理完成后继续启动 | inbound、清空 queue、切 `active` 一次提交 | | active 后发送 | 实时投递当前 batch | 保持不变 | @@ -249,17 +250,24 @@ sequenceDiagram 1. 创建状态为 `initializing` 的 Code Session; 2. 写入 sequence 1 的 `initialize` inbound; -3. 调用 `activateManagedAgentCodeSession` 完成 queue 交接; +3. 调用 `activateManagedAgentCodeSession`:合并历史 `session_events` 与 queue 后激活; 4. 激活成功后才继续签发并返回 runtime 启动信息; 5. 中途失败时,现有 defer cleanup 将未完成的 Code Session terminate。 -### 阶段一:读取并转换快照 +### 阶段一:读取历史与 queue 并转换 -`ListSessionEventQueueItems` 按 queue `id` 升序读取完整列表,并根据每个 -`session_event_uuid` 加载属于当前 Session 的正式事件。Service 在事务外将这些公开事件转换 -成 Code Session inbound inputs。 +Service 在事务外: + +1. `ListSessionEventQueueItems` 读取完整 queue(FIFO)及所属公开事件; +2. 分页 `ListSessionEventsPage`(升序)读取当前 Session 的公开事件历史; +3. 将可转发历史事件转为 inbound inputs,**排除**仍出现在 queue 中的 event UUID + (避免与 queue 交接重复); +4. 再按 queue 顺序转换 queue 中的 `user.message`; +5. 最终 inbound 顺序为:历史(去 queue 重复)→ queue FIFO。 转换放在事务外,避免在持有 Session 和 Code Session 行锁时执行 JSON/envelope 处理。 +queue 仍是启动空窗责任与 cutover 匹配的唯一来源;历史注入对齐旧 +`InitialEvents` 全量扫描,保证再起 Code Session 时能带上已有多轮对话。 ### 阶段二:一个事务完成全部交接 @@ -270,19 +278,20 @@ sequenceDiagram → 锁 initializing Code Session → 校验 queue item 均为 user.message(Session ownership 已由 List 保证) → 锁当前完整 queue -→ 将当前 queue 与事务外快照逐项比较 -→ 按 queue.id 顺序写入全部 inbound +→ 将当前 queue 与事务外 queue 快照逐项比较 +→ 按 inputs 顺序写入全部 inbound(历史 + queue) → 删除当前 Session 的全部 queue rows → Code Session initializing → active → commit ``` +`items` 仅用于 queue 快照匹配与清空;`inputs` 可长于 `items`。 快照比较包括 row 数量、queue ID、Session UUID、event UUID 和顺序。如果读取快照以后新消息 进入 queue,最终事务会发现列表不一致,返回 `activated=false`,且不做任何写入;Service -重新读取、转换并重试。 +重新读取历史与 queue、转换并重试。 inbound 插入复用现有 idempotency key。事务内每写一条新 inbound 都推进当前 Code Session -的 sequence,保证多条 Deployment initial messages 保持 FIFO 顺序。 +的 sequence,保证历史与 Deployment initial messages 保持稳定顺序。 ## 激活 cutover 的并发语义 diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index f5859b43..b51deb93 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -127,8 +127,12 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag }, nil } -// activateManagedAgentCodeSession hands off only explicitly queued startup -// messages, then activates while holding the same Session lock as send. +// activateManagedAgentCodeSession merges public session history with the +// startup queue into inbound, then activates under the same Session lock as send. +// +// Order: historical forwardable session_events (excluding UUIDs still in the +// queue) first, then queue items in FIFO order. Queue remains the sole source of +// startup-window responsibility and cutover matching. func (s *Service) activateManagedAgentCodeSession( ctx context.Context, session db.Session, @@ -139,26 +143,34 @@ func (s *Service) activateManagedAgentCodeSession( if err != nil { return err } - inputs, err := lo.MapErr(items, func(item db.SessionEventQueueItem, _ int) (db.AppendCodeSessionEventInput, error) { + history, err := s.listSessionEventsAscending(ctx, session) + if err != nil { + return err + } + queuedUUIDs := lo.SliceToMap(items, func(item db.SessionEventQueueItem) (string, struct{}) { + return item.Event.UUID, struct{}{} + }) + historyOnly := lo.Filter(history, func(event db.SessionEvent, _ int) bool { + _, queued := queuedUUIDs[event.UUID] + return !queued + }) + historyInputs, err := s.inboundInputsFromPublicSessionEvents(codeSession.ExternalID, historyOnly) + if err != nil { + return err + } + queueInputs, err := lo.MapErr(items, func(item db.SessionEventQueueItem, _ int) (db.AppendCodeSessionEventInput, error) { if item.Event.EventType != "user.message" { return db.AppendCodeSessionEventInput{}, fmt.Errorf( "%w: session event queue contains a non-user message", db.ErrInvalidState, ) } - payload, err := workerPayloadForPublicEvent( - codeSession.ExternalID, - item.Event.Payload, - item.Event.ProcessedAt, - ) - if err != nil { - return db.AppendCodeSessionEventInput{}, err - } - return newInboundEventInput(codeSession.ExternalID, payload, "public-session") + return s.inboundInputFromPublicSessionEvent(codeSession.ExternalID, item.Event) }) if err != nil { return err } + inputs := append(historyInputs, queueInputs...) activated, err := s.db.ActivateManagedAgentCodeSessionWithQueue( ctx, codeSession, @@ -174,6 +186,58 @@ func (s *Service) activateManagedAgentCodeSession( } } +func (s *Service) listSessionEventsAscending(ctx context.Context, session db.Session) ([]db.SessionEvent, error) { + var out []db.SessionEvent + var cursor *db.SessionEventPageCursor + for { + events, hasMore, err := s.db.ListSessionEventsPage(ctx, db.ListSessionEventsPageParams{ + WorkspaceID: session.WorkspaceID, + SessionExternalID: session.ExternalID, + Limit: 100, + Cursor: cursor, + Order: "asc", + }) + if err != nil { + return nil, err + } + out = append(out, events...) + if !hasMore || len(events) == 0 { + return out, nil + } + last := events[len(events)-1] + cursor = &db.SessionEventPageCursor{CreatedAt: last.CreatedAt, ID: last.ID} + } +} + +func (s *Service) inboundInputsFromPublicSessionEvents( + codeSessionID string, + events []db.SessionEvent, +) ([]db.AppendCodeSessionEventInput, error) { + inputs := make([]db.AppendCodeSessionEventInput, 0, len(events)) + for _, event := range events { + if !forwardPublicEventToWorker(event.EventType) { + continue + } + input, err := s.inboundInputFromPublicSessionEvent(codeSessionID, event) + if err != nil { + return nil, err + } + inputs = append(inputs, input) + } + return inputs, nil +} + +func (s *Service) inboundInputFromPublicSessionEvent( + codeSessionID string, + event db.SessionEvent, +) (db.AppendCodeSessionEventInput, error) { + payload, err := workerPayloadForPublicEvent(codeSessionID, event.Payload, event.ProcessedAt) + if err != nil { + return db.AppendCodeSessionEventInput{}, err + } + return newInboundEventInput(codeSessionID, payload, "public-session") +} + // TerminateManagedAgentCodeSession revokes a Code Session created for a // sandbox launch that failed before the runtime became usable. func (s *Service) TerminateManagedAgentCodeSession( diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 37a42e48..721c8b23 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -285,18 +285,16 @@ func (d *DB) CreateCodeSession(ctx context.Context, input CreateCodeSessionInput }) } -// ActivateManagedAgentCodeSessionWithQueue atomically transfers the complete -// startup queue to inbound, clears the temporary responsibility, and activates -// the Code Session while holding the same Session lock as event sends. +// ActivateManagedAgentCodeSessionWithQueue writes inbound inputs (typically +// historical public events plus startup-queue messages), clears the matched +// startup queue snapshot, and activates the Code Session under the Session lock. +// items is only the queue snapshot used for match/clear; inputs may be longer. func (d *DB) ActivateManagedAgentCodeSessionWithQueue( ctx context.Context, codeSession CodeSession, items []SessionEventQueueItem, inputs []AppendCodeSessionEventInput, ) (bool, error) { - if len(items) != len(inputs) { - return false, ErrInvalidState - } tx, err := d.sql.BeginTxx(ctx, nil) if err != nil { return false, err From 7eef483f04f366713ae03cc47ff0148fa19dae69 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Fri, 31 Jul 2026 09:41:07 +0800 Subject: [PATCH 07/23] refactor(db): rename session startup window helper Rename sessionUserMessageStartupWindowSQLX to shouldQueueUserMessageForStartupSQLX and update local variables from startup to shouldQueueForStartup for clarity. Also update the design doc references to match the new function name. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/be/session-startup-message-delivery.md | 4 ++-- internal/db/deployments.go | 4 ++-- internal/db/session_event_queue.go | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 272d0b7f..15eee5c0 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -156,7 +156,7 @@ queue 或激活 Code Session。 启动窗口是后端事务内的判断,不是新的公开状态。 -`sessionUserMessageStartupWindowSQLX` 的规则是: +`shouldQueueUserMessageForStartupSQLX` 的规则是: 1. 查询该 Session 最新且未删除的 Code Session; 2. 如果 Code Session 存在且状态不是 `initializing`,不进入 startup queue; @@ -393,7 +393,7 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | Runner prepare 不再读取事件快照 | `Runner.prepareManagedAgentLaunch` | | API 标准化事件与 outcome | `Handler.sendEventsRoute`、`normalizeInputEvent` | | Send 事务和 startup/realtime 分流 | `DB.AppendSessionEventsForDelivery` | -| 启动窗口判断 | `sessionUserMessageStartupWindowSQLX` | +| 启动窗口判断 | `shouldQueueUserMessageForStartupSQLX` | | queue 写入 | `enqueueSessionEventsSQLXTx` | | queue 快照及 ownership 加载 | `DB.ListSessionEventQueueItems` | | Code Session 创建和消费循环 | `Service.CreateManagedAgentCodeSession`、`activateManagedAgentCodeSession` | diff --git a/internal/db/deployments.go b/internal/db/deployments.go index 94a07bf5..074c4c36 100644 --- a/internal/db/deployments.go +++ b/internal/db/deployments.go @@ -282,11 +282,11 @@ func (d *DB) CreateManualDeploymentRun(ctx context.Context, input CreateManualDe if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } - startup, err := sessionUserMessageStartupWindowSQLX(ctx, tx, session) + shouldQueueForStartup, err := shouldQueueUserMessageForStartupSQLX(ctx, tx, session) if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } - if startup { + if shouldQueueForStartup { if err := enqueueSessionEventsSQLXTx(ctx, tx, session, events); err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index d028f4eb..dbc7fb28 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -65,14 +65,14 @@ func (d *DB) AppendSessionEventsForDelivery( userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { return event.EventType == "user.message" }) - startup := false + shouldQueueForStartup := false if userMessageCount > 0 { - startup, err = sessionUserMessageStartupWindowSQLX(ctx, tx, session) + shouldQueueForStartup, err = shouldQueueUserMessageForStartupSQLX(ctx, tx, session) if err != nil { return nil, "", err } } - if startup { + if shouldQueueForStartup { if len(events) != 1 || userMessageCount != 1 { return nil, "", ErrSessionStartupMessageConflict } @@ -90,7 +90,7 @@ func (d *DB) AppendSessionEventsForDelivery( return nil, "", err } delivery := SessionEventDeliveryRealtime - if startup { + if shouldQueueForStartup { if err := enqueueSessionEventsSQLXTx(ctx, tx, session, created); err != nil { return nil, "", err } @@ -209,7 +209,7 @@ func sessionEventQueueItemsMatch( return true } -func sessionUserMessageStartupWindowSQLX( +func shouldQueueUserMessageForStartupSQLX( ctx context.Context, database sqlxNamedQueryer, session Session, From c79cbf54ad61a831beea173c371cff97b6cef589 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Fri, 31 Jul 2026 09:55:15 +0800 Subject: [PATCH 08/23] refactor(db): simplify session event queue existence check Remove unnecessary joins to organizations and workspaces in sessionEventQueueExistsSQLX. The queue row is identified by session_uuid, which is already unique across the system. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/session_event_queue.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index dbc7fb28..38541f6d 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -257,7 +257,6 @@ func shouldQueueUserMessageForStartupSQLX( and ew.state in ('queued', 'starting', 'active') and ew.deleted_at is null and e.deleted_at is null - and e.config->>'type' = 'cloud' ) `, map[string]any{ "organization_id": session.OrganizationID, @@ -282,16 +281,10 @@ func sessionEventQueueExistsSQLX( select exists ( select 1 from session_event_queue q - join organizations o on o.uuid = q.organization_uuid - join workspaces w on w.uuid = q.workspace_uuid and w.organization_id = o.id - where o.id = :organization_id - and w.id = :workspace_id - and q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = CAST(:session_uuid AS uuid) ) `, map[string]any{ - "organization_id": session.OrganizationID, - "workspace_id": session.WorkspaceID, - "session_uuid": session.UUID, + "session_uuid": session.UUID, }) return exists, err } From 2c0680047a3482e21130c4832f25f9249ca3eb07 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Fri, 31 Jul 2026 10:08:00 +0800 Subject: [PATCH 09/23] refactor(db): simplify listSessionEventQueueIdentityRows Remove unnecessary joins to organizations and workspaces in listSessionEventQueueIdentityRows. session_uuid is sufficient to identify queue rows for a Session. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/session_event_queue.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 38541f6d..84110efa 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -170,11 +170,7 @@ func listSessionEventQueueIdentityRows( select q.id, CAST(q.session_uuid AS text) as session_uuid, CAST(q.session_event_uuid AS text) as session_event_uuid from session_event_queue q - join organizations o on o.uuid = q.organization_uuid - join workspaces w on w.uuid = q.workspace_uuid and w.organization_id = o.id - where o.id = :organization_id - and w.id = :workspace_id - and q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = CAST(:session_uuid AS uuid) order by q.id asc ` if lock { @@ -182,9 +178,7 @@ func listSessionEventQueueIdentityRows( } var rows []sessionEventQueueIdentityRow err := namedSelectContext(ctx, database, &rows, query, map[string]any{ - "organization_id": session.OrganizationID, - "workspace_id": session.WorkspaceID, - "session_uuid": session.UUID, + "session_uuid": session.UUID, }) if err != nil { return nil, err @@ -295,6 +289,7 @@ func enqueueSessionEventsSQLXTx( session Session, events []SessionEvent, ) error { + // todo batch for _, event := range lo.Filter(events, func(event SessionEvent, _ int) bool { return event.EventType == "user.message" }) { From ee3eee65b1699af5101cf473ebdf302d10bb5953 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Fri, 31 Jul 2026 10:33:06 +0800 Subject: [PATCH 10/23] refactor(db): simplify ListSessionEventQueueItems event lookup Lookup queued session events only by their UUID and deleted_at state. The queue rows are already scoped to the Session via session_uuid, so the organization_id, workspace_id, session_id, and session_external_id filters are redundant. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/session_event_queue.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 84110efa..fb14334f 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -126,18 +126,10 @@ func (d *DB) ListSessionEventQueueItems( event, err := getSessionEventSQLX(ctx, d.sql, ` select `+sessionEventSQLXColumns+` from session_events - where organization_id = :organization_id - and workspace_id = :workspace_id - and uuid = CAST(:session_event_uuid AS uuid) - and session_id = :session_id - and session_external_id = :session_external_id + where uuid = CAST(:session_event_uuid AS uuid) and deleted_at is null `, map[string]any{ - "organization_id": session.OrganizationID, - "workspace_id": session.WorkspaceID, - "session_event_uuid": row.SessionEventUUID, - "session_id": session.ID, - "session_external_id": session.ExternalID, + "session_event_uuid": row.SessionEventUUID, }) if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf( From e05b3c75a5e680ca6cca591575e6c4442fbb0965 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Fri, 31 Jul 2026 11:11:01 +0800 Subject: [PATCH 11/23] refactor(db): simplify delete session event queue query Remove organizations/workspaces join from deleteSessionEventQueueQuery. The queue rows can be deleted directly by session_uuid. Update DeleteSession to stop injecting organization_id and update the named-argument binding test accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/sessions.go | 1 - internal/db/sessions_migration_sqlx_test.go | 6 ++---- internal/db/sessions_sqlx.go | 8 +------- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/internal/db/sessions.go b/internal/db/sessions.go index 65df0ca4..d96f2305 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -324,7 +324,6 @@ func (d *DB) DeleteSession(ctx context.Context, workspaceID int64, externalID st if _, err := namedExecContext(ctx, tx, deleteSessionResourcesQuery, arguments); err != nil { return Session{}, err } - arguments["organization_id"] = session.OrganizationID arguments["session_uuid"] = session.UUID if _, err := namedExecContext(ctx, tx, deleteSessionEventQueueQuery, arguments); err != nil { return Session{}, err diff --git a/internal/db/sessions_migration_sqlx_test.go b/internal/db/sessions_migration_sqlx_test.go index 412b2c1c..7a87ec6a 100644 --- a/internal/db/sessions_migration_sqlx_test.go +++ b/internal/db/sessions_migration_sqlx_test.go @@ -122,11 +122,9 @@ func TestMigratedSessionQueriesBindNamedArguments(t *testing.T) { name: "delete session event queue", query: deleteSessionEventQueueQuery, arguments: map[string]any{ - "organization_id": int64(1), - "workspace_id": int64(2), - "session_uuid": "11111111-1111-4111-8111-111111111111", + "session_uuid": "11111111-1111-4111-8111-111111111111", }, - wantArgCount: 3, + wantArgCount: 1, }, { name: "stop environment work", diff --git a/internal/db/sessions_sqlx.go b/internal/db/sessions_sqlx.go index a1791aa3..c95cd25b 100644 --- a/internal/db/sessions_sqlx.go +++ b/internal/db/sessions_sqlx.go @@ -126,13 +126,7 @@ const ( ` deleteSessionEventQueueQuery = ` delete from session_event_queue q - using organizations o, workspaces w - where q.organization_uuid = o.uuid - and q.workspace_uuid = w.uuid - and w.organization_id = o.id - and o.id = :organization_id - and w.id = :workspace_id - and q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = CAST(:session_uuid AS uuid) ` stopDeletedSessionEnvironmentWorkQuery = ` update environment_work From 88033f502cb020cfe1785c477f630f52fa52f421 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 11:44:29 +0800 Subject: [PATCH 12/23] fix(db): renumber session event queue migration after main UUID series Main claimed 00036/00038 for tenant UUID work, so the startup queue table now ships as 00047 with organization/workspace UUID columns from the start. Update the queue listing test helper joins to the same UUID references. --- ..._session_event_queue_tenant_references.sql | 106 ------------------ ....sql => 00047_add_session_event_queue.sql} | 6 +- tests/sessions_api_test.go | 10 +- 3 files changed, 8 insertions(+), 114 deletions(-) delete mode 100644 internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql rename internal/db/migrations/{00036_add_session_event_queue.sql => 00047_add_session_event_queue.sql} (79%) diff --git a/internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql b/internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql deleted file mode 100644 index 476fa80d..00000000 --- a/internal/db/migrations/00038_use_uuid_session_event_queue_tenant_references.sql +++ /dev/null @@ -1,106 +0,0 @@ --- +goose Up - --- Queue ownership must survive tenant identity remapping during data moves. -create table session_event_queue_uuid_refs ( - id bigint generated always as identity, - uuid uuid not null default gen_random_uuid(), - organization_uuid uuid not null, - workspace_uuid uuid not null, - session_uuid uuid not null, - session_event_uuid uuid not null, - created_at timestamptz not null default now(), - constraint session_event_queue_uuid_refs_id_pk primary key (id), - constraint session_event_queue_uuid_refs_uuid_key unique (uuid), - constraint session_event_queue_uuid_refs_event_uuid_key unique (session_event_uuid) -); - -insert into session_event_queue_uuid_refs ( - id, uuid, organization_uuid, workspace_uuid, session_uuid, session_event_uuid, created_at -) -overriding system value -select q.id, q.uuid, o.uuid, w.uuid, q.session_uuid, q.session_event_uuid, q.created_at -from session_event_queue q -join organizations o on o.id = q.organization_id -join workspaces w on w.id = q.workspace_id and w.organization_id = o.id -order by q.id; - --- +goose StatementBegin -do $$ -begin - if (select count(*) from session_event_queue_uuid_refs) <> - (select count(*) from session_event_queue) then - raise exception 'cannot migrate session event queue tenant references to UUID'; - end if; -end $$; --- +goose StatementEnd - -select setval( - pg_get_serial_sequence('session_event_queue_uuid_refs', 'id'), - coalesce((select max(id) from session_event_queue_uuid_refs), 1), - exists (select 1 from session_event_queue_uuid_refs) -); - -drop table session_event_queue; -alter table session_event_queue_uuid_refs rename to session_event_queue; -alter table session_event_queue rename constraint session_event_queue_uuid_refs_id_pk - to session_event_queue_id_pk; -alter table session_event_queue rename constraint session_event_queue_uuid_refs_uuid_key - to session_event_queue_uuid_key; -alter table session_event_queue rename constraint session_event_queue_uuid_refs_event_uuid_key - to session_event_queue_session_event_uuid_key; - -create index session_event_queue_session_order_v2_idx - on session_event_queue (organization_uuid, workspace_uuid, session_uuid, id asc); - --- +goose Down - -create table session_event_queue_identity_refs ( - id bigint generated always as identity, - uuid uuid not null default gen_random_uuid(), - organization_id bigint not null, - workspace_id bigint not null, - session_uuid uuid not null, - session_event_uuid uuid not null, - created_at timestamptz not null default now(), - constraint session_event_queue_identity_refs_id_pk primary key (id), - constraint session_event_queue_identity_refs_uuid_key unique (uuid), - constraint session_event_queue_identity_refs_event_uuid_key unique (session_event_uuid) -); - -insert into session_event_queue_identity_refs ( - id, uuid, organization_id, workspace_id, session_uuid, session_event_uuid, created_at -) -overriding system value -select q.id, q.uuid, o.id, w.id, q.session_uuid, q.session_event_uuid, q.created_at -from session_event_queue q -join organizations o on o.uuid = q.organization_uuid -join workspaces w on w.uuid = q.workspace_uuid and w.organization_id = o.id -order by q.id; - --- +goose StatementBegin -do $$ -begin - if (select count(*) from session_event_queue_identity_refs) <> - (select count(*) from session_event_queue) then - raise exception 'cannot restore session event queue tenant identity references'; - end if; -end $$; --- +goose StatementEnd - -select setval( - pg_get_serial_sequence('session_event_queue_identity_refs', 'id'), - coalesce((select max(id) from session_event_queue_identity_refs), 1), - exists (select 1 from session_event_queue_identity_refs) -); - -drop table session_event_queue; -alter table session_event_queue_identity_refs rename to session_event_queue; -alter table session_event_queue rename constraint session_event_queue_identity_refs_id_pk - to session_event_queue_id_pk; -alter table session_event_queue rename constraint session_event_queue_identity_refs_uuid_key - to session_event_queue_uuid_key; -alter table session_event_queue rename constraint session_event_queue_identity_refs_event_uuid_key - to session_event_queue_session_event_uuid_key; - -create index session_event_queue_session_order_v1_idx - on session_event_queue (organization_id, workspace_id, session_uuid, id asc); diff --git a/internal/db/migrations/00036_add_session_event_queue.sql b/internal/db/migrations/00047_add_session_event_queue.sql similarity index 79% rename from internal/db/migrations/00036_add_session_event_queue.sql rename to internal/db/migrations/00047_add_session_event_queue.sql index 37cc51c3..ded593cd 100644 --- a/internal/db/migrations/00036_add_session_event_queue.sql +++ b/internal/db/migrations/00047_add_session_event_queue.sql @@ -2,8 +2,8 @@ create table session_event_queue ( id bigint generated always as identity, uuid uuid not null default gen_random_uuid(), - organization_id bigint not null, - workspace_id bigint not null, + organization_uuid uuid not null, + workspace_uuid uuid not null, session_uuid uuid not null, session_event_uuid uuid not null, created_at timestamptz not null default now(), @@ -13,7 +13,7 @@ create table session_event_queue ( ); create index session_event_queue_session_order_v1_idx - on session_event_queue (organization_id, workspace_id, session_uuid, id asc); + on session_event_queue (organization_uuid, workspace_uuid, session_uuid, id asc); -- +goose Down drop table if exists session_event_queue; diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 5b5c3b54..c3cb01a4 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -3933,15 +3933,15 @@ func sessionEventQueueEventIDs(t *testing.T, app *testApp, sessionID string) []s join organizations o on o.uuid = q.organization_uuid join workspaces w on w.uuid = q.workspace_uuid - and w.organization_id = o.id + and w.organization_uuid = o.uuid join sessions s on s.uuid = q.session_uuid - and s.organization_id = o.id - and s.workspace_id = w.id + and s.organization_uuid = o.uuid + and s.workspace_uuid = w.uuid join session_events e on e.uuid = q.session_event_uuid - and e.organization_id = o.id - and e.workspace_id = w.id + and e.organization_uuid = o.uuid + and e.workspace_uuid = w.uuid where s.external_id = $1 order by q.id asc `, sessionID) From 3609ca34610a03dbae9f2c49664998e046699316 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 13:04:21 +0800 Subject: [PATCH 13/23] revert: drop unrelated merge fixes from startup-delivery branch Restore GetRenewableEnvironmentSandboxForCodeSession and e2b integration tests to match main. Those changes fixed main UUID/schema issues outside the session startup event-queue feature and should not ship in this PR. --- internal/db/environments.go | 20 ++++++------ tests/environments_e2b_integration_test.go | 32 +++++++++---------- ...nments_full_e2b_bridge_integration_test.go | 2 +- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/internal/db/environments.go b/internal/db/environments.go index 82cb6826..16c07046 100644 --- a/internal/db/environments.go +++ b/internal/db/environments.go @@ -719,29 +719,29 @@ func (d *DB) GetRenewableEnvironmentSandboxForCodeSession(ctx context.Context, c return getEnvironmentSandboxSQLX(ctx, d.sql, ` select `+environmentSandboxSQLXColumns+` from environment_sandboxes - where uuid = ( - select sandbox.uuid + where id = ( + select sandbox.id from code_sessions code_session join environment_work work - on work.organization_uuid = code_session.organization_uuid - and work.workspace_uuid = code_session.workspace_uuid - and work.environment_uuid = code_session.environment_uuid + on work.organization_id = code_session.organization_id + and work.workspace_id = code_session.workspace_id + and work.environment_id = code_session.environment_id and work.environment_external_id = code_session.environment_external_id and work.data->>'type' = 'session' and work.data->>'id' = code_session.session_external_id and work.deleted_at is null join environment_sandboxes sandbox - on sandbox.organization_uuid = code_session.organization_uuid - and sandbox.workspace_uuid = code_session.workspace_uuid - and sandbox.environment_uuid = code_session.environment_uuid - and sandbox.work_uuid = work.uuid + on sandbox.organization_id = code_session.organization_id + and sandbox.workspace_id = code_session.workspace_id + and sandbox.environment_id = code_session.environment_id + and sandbox.work_id = work.id and sandbox.provider_sandbox_id is not null and sandbox.state = 'running' where code_session.external_id = :code_session_external_id and code_session.status = 'active' and code_session.worker_status = 'running' and code_session.deleted_at is null - order by sandbox.created_at desc, sandbox.uuid desc + order by sandbox.created_at desc, sandbox.id desc limit 1 ) `, map[string]any{"code_session_external_id": codeSessionExternalID}) diff --git a/tests/environments_e2b_integration_test.go b/tests/environments_e2b_integration_test.go index de7a615d..e01a6c22 100644 --- a/tests/environments_e2b_integration_test.go +++ b/tests/environments_e2b_integration_test.go @@ -100,18 +100,18 @@ func TestE2BEnvironmentRunnerIntegration(t *testing.T) { }) now := time.Now().UTC() env, err := database.CreateEnvironment(ctx, db.Environment{ - UUID: uuid.NewString(), - ExternalID: envID, - OrganizationUUID: apiKey.OrganizationUUID, - WorkspaceUUID: apiKey.WorkspaceUUID, - CreatedByAPIKeyUUID: apiKey.UUID, - Name: "e2b-integration-" + envID[len("env_"):len("env_")+8], - Description: "Real E2B integration smoke test", - Config: envConfig, - Metadata: mustJSON(t, map[string]any{"source": "e2b_integration_test"}), - Provider: "e2b", - ResolvedTemplate: template, - CreatedAt: now, + UUID: uuid.NewString(), + ExternalID: envID, + OrganizationID: apiKey.OrganizationID, + WorkspaceID: apiKey.WorkspaceID, + CreatedByAPIKeyID: apiKey.ID, + Name: "e2b-integration-" + envID[len("env_"):len("env_")+8], + Description: "Real E2B integration smoke test", + Config: envConfig, + Metadata: mustJSON(t, map[string]any{"source": "e2b_integration_test"}), + Provider: "e2b", + ResolvedTemplate: template, + CreatedAt: now, }) if err != nil { t.Fatalf("create environment: %v", err) @@ -121,9 +121,9 @@ func TestE2BEnvironmentRunnerIntegration(t *testing.T) { work, err := database.CreateEnvironmentWork(ctx, db.EnvironmentWork{ UUID: uuid.NewString(), ExternalID: workID, - OrganizationUUID: env.OrganizationUUID, - WorkspaceUUID: env.WorkspaceUUID, - EnvironmentUUID: env.UUID, + OrganizationID: env.OrganizationID, + WorkspaceID: env.WorkspaceID, + EnvironmentID: env.ID, EnvironmentExternalID: env.ExternalID, Data: mustJSON(t, map[string]any{"task": "e2b integration smoke"}), Metadata: mustJSON(t, map[string]any{"source": "e2b_integration_test"}), @@ -232,7 +232,7 @@ func TestE2BEnvironmentRunnerIntegration(t *testing.T) { if stoppedSandboxState != "stopped" { t.Fatalf("sandbox state after stop = %s, want stopped", stoppedSandboxState) } - stoppedWork, err := database.GetEnvironmentWork(ctx, env.WorkspaceUUID, env.ExternalID, work.ExternalID) + stoppedWork, err := database.GetEnvironmentWork(ctx, env.WorkspaceID, env.ExternalID, work.ExternalID) if err != nil { t.Fatalf("load stopped work: %v", err) } diff --git a/tests/environments_full_e2b_bridge_integration_test.go b/tests/environments_full_e2b_bridge_integration_test.go index 9508d70e..fa77a28a 100644 --- a/tests/environments_full_e2b_bridge_integration_test.go +++ b/tests/environments_full_e2b_bridge_integration_test.go @@ -160,7 +160,7 @@ func TestE2BManagedAgentBridgeEnvironmentManagerIntegration(t *testing.T) { } sandboxRecord, err := app.db.GetActiveEnvironmentSandboxForWork( ctx, - getDefaultDBIDs(t, app.db).WorkspaceUUID, + getDefaultDBIDs(t, app.db).WorkspaceID, environment.ID, workID, ) From c5a63a4b613fb30f6ec696c6ec50f20cb9a3a804 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 13:08:50 +0800 Subject: [PATCH 14/23] refactor(db): simplify startup queue SQL and drop cloud filter docs Query and lock session_event_queue and activation rows by stable UUIDs only, drop environment-type gating from the design contract, and keep startup-window checks aligned with session-scoped environment work. --- .../be/session-startup-message-delivery.md | 19 +++---- internal/db/code_sessions.go | 32 +++--------- internal/db/session_event_queue.go | 51 +++++++------------ tests/sessions_api_test.go | 14 +---- 4 files changed, 36 insertions(+), 80 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 15eee5c0..0a48a679 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -148,9 +148,10 @@ sequenceDiagram 事件重复获得两条 queue 责任。organization、workspace、Session 和 event 均使用稳定 UUID 引用,避免租户迁移、部分导入或跨库合并时 identity 重映射导致 queue row 失去归属。 -读取 queue 引用时必须同时匹配 organization、workspace、Session ID、Session external ID -和 event UUID。queue 如果指向其他 Session 的事件,创建流程直接失败,不得写 inbound、删 -queue 或激活 Code Session。 +queue 行按 `session_uuid` 归属当前 Session;读取 event 时按 `session_event_uuid` +定位 `session_events`。queue 引用的事件不存在或无法归属时,创建流程直接失败,不得写 +inbound、删 queue 或激活 Code Session。写入时仍落 `organization_uuid` / +`workspace_uuid`,作为稳定租户字段,但不作为查询必要条件。 ## 启动窗口判定 @@ -158,13 +159,13 @@ queue 或激活 Code Session。 `shouldQueueUserMessageForStartupSQLX` 的规则是: -1. 查询该 Session 最新且未删除的 Code Session; +1. 查询该 Session 最新且未删除的 Code Session(按 `session_uuid`); 2. 如果 Code Session 存在且状态不是 `initializing`,不进入 startup queue; 3. 如果 Code Session 不存在或仍为 `initializing`,检查对应 Environment Work; -4. work data 必须指向当前 Session,且状态为 `queued`、`starting` 或 `active`; -5. Environment 必须是 cloud(`config.type = cloud`),与 Runner 只对 cloud 创建 - managed Code Session 并激活 queue 的路径一致;`self_hosted` 等非 cloud 环境不进 - startup queue,避免无人 drain 时首条消息挂死、后续消息 409; +4. work 必须落在当前 Session 的 `workspace_uuid` / `environment_uuid` 上,data 指向 + 当前 Session,且状态为 `queued`、`starting` 或 `active`; +5. **不按 Environment 类型过滤**(例如不区分 `cloud` / `self_hosted`)。是否入队只取决于 + Code Session 是否尚未 active,以及是否仍有指向该 Session 的在途 environment work; 6. work 已停止或最新 Code Session 是其他状态时,保持既有事件行为。 这个判断必须在 Send 事务锁住 Session 行之后执行,不能在 API 层提前查询。发送和激活只有 @@ -418,4 +419,4 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | Send 事务先于激活获得 Session 锁 | 消息进入 queue,激活发现变化后重试 | | Code Session 已 active | 不写 startup queue,只实时投当前 batch | | Code Session 非 active | 不实时写 inbound | -| Environment 为 self_hosted 等非 cloud | 不进 startup queue;消息走既有 realtime/no-op 路径,不 409 | +| Environment 类型为 self_hosted 等 | 与 cloud 相同:只要 CS 未 active 且存在指向该 Session 的在途 work,即可能进 startup queue;**不**因 environment type 跳过 queue | diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 8c2443a9..fdb96b2b 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -307,18 +307,12 @@ func (d *DB) ActivateManagedAgentCodeSessionWithQueue( current, err := getCodeSessionSQLX(ctx, tx, ` select `+codeSessionColumns()+` from code_sessions - where organization_uuid = CAST(:organization_uuid AS uuid) - and workspace_uuid = CAST(:workspace_uuid AS uuid) - and external_id = :external_id - and session_uuid = CAST(:session_uuid AS uuid) + where uuid = CAST(:uuid AS uuid) and status = 'initializing' and deleted_at is null for update `, map[string]any{ - "organization_uuid": codeSession.OrganizationUUID, - "workspace_uuid": codeSession.WorkspaceUUID, - "external_id": codeSession.ExternalID, - "session_uuid": session.UUID, + "uuid": codeSession.UUID, }) if err != nil { return false, err @@ -352,13 +346,9 @@ func (d *DB) ActivateManagedAgentCodeSessionWithQueue( } deletedResult, err := namedExecContext(ctx, tx, ` delete from session_event_queue - where organization_uuid = CAST(:organization_uuid AS uuid) - and workspace_uuid = CAST(:workspace_uuid AS uuid) - and session_uuid = CAST(:session_uuid AS uuid) + where session_uuid = CAST(:session_uuid AS uuid) `, map[string]any{ - "organization_uuid": session.OrganizationUUID, - "workspace_uuid": session.WorkspaceUUID, - "session_uuid": session.UUID, + "session_uuid": session.UUID, }) if err != nil { return false, err @@ -374,20 +364,12 @@ func (d *DB) ActivateManagedAgentCodeSessionWithQueue( result, err := namedExecContext(ctx, tx, ` update code_sessions set status = 'active', updated_at = :now - where organization_uuid = CAST(:organization_uuid AS uuid) - and workspace_uuid = CAST(:workspace_uuid AS uuid) - and uuid = CAST(:uuid AS uuid) - and external_id = :external_id - and session_uuid = CAST(:session_uuid AS uuid) + where uuid = CAST(:uuid AS uuid) and status = 'initializing' and deleted_at is null `, map[string]any{ - "organization_uuid": current.OrganizationUUID, - "workspace_uuid": current.WorkspaceUUID, - "uuid": current.UUID, - "external_id": current.ExternalID, - "session_uuid": session.UUID, - "now": time.Now().UTC(), + "uuid": current.UUID, + "now": time.Now().UTC(), }) if err != nil { return false, err diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 11c626b5..88276d9c 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -158,13 +158,13 @@ func listSessionEventQueueIdentityRows( session Session, lock bool, ) ([]sessionEventQueueIdentityRow, error) { + // session_uuid uniquely identifies the public Session; tenant columns are + // written on insert but are not required as query predicates. query := ` select q.id, CAST(q.session_uuid AS text) as session_uuid, CAST(q.session_event_uuid AS text) as session_event_uuid from session_event_queue q - where q.organization_uuid = CAST(:organization_uuid AS uuid) - and q.workspace_uuid = CAST(:workspace_uuid AS uuid) - and q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = CAST(:session_uuid AS uuid) order by q.id asc ` if lock { @@ -172,9 +172,7 @@ func listSessionEventQueueIdentityRows( } var rows []sessionEventQueueIdentityRow err := namedSelectContext(ctx, database, &rows, query, map[string]any{ - "organization_uuid": session.OrganizationUUID, - "workspace_uuid": session.WorkspaceUUID, - "session_uuid": session.UUID, + "session_uuid": session.UUID, }) if err != nil { return nil, err @@ -199,6 +197,11 @@ func sessionEventQueueItemsMatch( return true } +// shouldQueueUserMessageForStartupSQLX reports whether a user.message should +// enter the startup queue. Environment type (for example cloud vs self_hosted) +// is intentionally not part of this decision: queueing depends only on whether +// the Session still has no active Code Session and still has session-scoped +// environment work in flight. func shouldQueueUserMessageForStartupSQLX( ctx context.Context, database sqlxNamedQueryer, @@ -208,16 +211,12 @@ func shouldQueueUserMessageForStartupSQLX( err := namedGetContext(ctx, database, &status, ` select status from code_sessions - where organization_uuid = CAST(:organization_uuid AS uuid) - and workspace_uuid = CAST(:workspace_uuid AS uuid) - and session_uuid = CAST(:session_uuid AS uuid) + where session_uuid = CAST(:session_uuid AS uuid) and deleted_at is null order by created_at desc, uuid desc limit 1 `, map[string]any{ - "organization_uuid": session.OrganizationUUID, - "workspace_uuid": session.WorkspaceUUID, - "session_uuid": session.UUID, + "session_uuid": session.UUID, }) if err == nil && status != "initializing" { return false, nil @@ -226,34 +225,22 @@ func shouldQueueUserMessageForStartupSQLX( return false, err } - // Only cloud environments run managed Code Session activation that drains - // the queue (see Runner.prepareManagedAgentLaunch / cloudEnvironment). var startupWorkExists bool err = namedGetContext(ctx, database, &startupWorkExists, ` select exists ( select 1 from environment_work ew - join environments e - on e.uuid = ew.environment_uuid - and e.organization_uuid = ew.organization_uuid - and e.workspace_uuid = ew.workspace_uuid - and e.external_id = ew.environment_external_id - where ew.organization_uuid = CAST(:organization_uuid AS uuid) - and ew.workspace_uuid = CAST(:workspace_uuid AS uuid) + where ew.workspace_uuid = CAST(:workspace_uuid AS uuid) and ew.environment_uuid = CAST(:environment_uuid AS uuid) - and ew.environment_external_id = :environment_external_id and ew.data->>'type' = 'session' and ew.data->>'id' = :session_external_id and ew.state in ('queued', 'starting', 'active') and ew.deleted_at is null - and e.deleted_at is null ) `, map[string]any{ - "organization_uuid": session.OrganizationUUID, - "workspace_uuid": session.WorkspaceUUID, - "environment_uuid": session.EnvironmentUUID, - "environment_external_id": session.EnvironmentExternalID, - "session_external_id": session.ExternalID, + "workspace_uuid": session.WorkspaceUUID, + "environment_uuid": session.EnvironmentUUID, + "session_external_id": session.ExternalID, }) if err != nil { return false, err @@ -271,14 +258,10 @@ func sessionEventQueueExistsSQLX( select exists ( select 1 from session_event_queue q - where q.organization_uuid = CAST(:organization_uuid AS uuid) - and q.workspace_uuid = CAST(:workspace_uuid AS uuid) - and q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = CAST(:session_uuid AS uuid) ) `, map[string]any{ - "organization_uuid": session.OrganizationUUID, - "workspace_uuid": session.WorkspaceUUID, - "session_uuid": session.UUID, + "session_uuid": session.UUID, }) return exists, err } diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index c3cb01a4..a624f81c 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -3930,18 +3930,8 @@ func sessionEventQueueEventIDs(t *testing.T, app *testApp, sessionID string) []s rows, err := app.db.Pool.Query(context.Background(), ` select e.external_id from session_event_queue q - join organizations o on o.uuid = q.organization_uuid - join workspaces w - on w.uuid = q.workspace_uuid - and w.organization_uuid = o.uuid - join sessions s - on s.uuid = q.session_uuid - and s.organization_uuid = o.uuid - and s.workspace_uuid = w.uuid - join session_events e - on e.uuid = q.session_event_uuid - and e.organization_uuid = o.uuid - and e.workspace_uuid = w.uuid + join sessions s on s.uuid = q.session_uuid + join session_events e on e.uuid = q.session_event_uuid and e.deleted_at is null where s.external_id = $1 order by q.id asc `, sessionID) From 080310a75f04061d8cd19a03c7c7a5e633426c14 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Fri, 31 Jul 2026 23:51:55 +0800 Subject: [PATCH 15/23] refactor(sessions): move activation tx orchestration out of DB Own managed-agent activation ordering in the code-session service via WithManagedAgentActivationTx, and replace sentinel startup/archived errors with typed errors so Send Events maps HTTP status correctly. --- .../be/session-startup-message-delivery.md | 8 +- .../managed_agent_code_session.go | 73 +++++++++++- internal/db/code_sessions.go | 112 ++++-------------- internal/db/db.go | 27 ++--- internal/db/managed_agent_activation.go | 32 +++++ internal/db/session_event_queue.go | 101 ++++++++++------ internal/db/sessions.go | 2 +- internal/db/sessions_sqlx.go | 15 ++- internal/sessions/service.go | 12 +- tests/sessions_api_test.go | 8 +- 10 files changed, 240 insertions(+), 150 deletions(-) create mode 100644 internal/db/managed_agent_activation.go diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 0a48a679..a07d1831 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -195,7 +195,7 @@ normalization 阶段写库。 2. 拒绝 archived Session; 3. batch 包含 `user.message` 时判断启动窗口; 4. 启动窗口只允许 batch 恰好包含一条 `user.message`; -5. queue 已有任何 row 时返回 `ErrSessionStartupMessageConflict`; +5. queue 已有任何 row 时返回 `SessionStartupMessageConflictError`; 6. 写入 `session_events`; 7. 启动窗口内再写入对应 queue row; 8. 有 outcome 变化时在同一事务更新; @@ -272,7 +272,9 @@ queue 仍是启动空窗责任与 cutover 匹配的唯一来源;历史注入 ### 阶段二:一个事务完成全部交接 -`ActivateManagedAgentCodeSessionWithQueue` 固定执行: +`Service.ActivateManagedAgentCodeSessionWithQueue` 通过 +`DB.WithManagedAgentActivationTx` 定义事务边界并固定执行以下顺序;DB 的事务对象只暴露 +Session、queue 和 Code Session 各自的 SQL 操作,不编排跨资源业务流程: ```text 锁 Session @@ -398,7 +400,7 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | queue 写入 | `enqueueSessionEventsSQLXTx` | | queue 快照及 ownership 加载 | `DB.ListSessionEventQueueItems` | | Code Session 创建和消费循环 | `Service.CreateManagedAgentCodeSession`、`activateManagedAgentCodeSession` | -| 完整 queue 原子交接与激活 | `DB.ActivateManagedAgentCodeSessionWithQueue` | +| 完整 queue 原子交接与激活 | `Service.ActivateManagedAgentCodeSessionWithQueue`、`DB.WithManagedAgentActivationTx` | | Deployment initial events 入队 | `DB.CreateManualDeploymentRun` | | active 当前 batch 投递 | `Service.QueuePublicSessionEvents` | diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index f9e2097f..3400d890 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -171,7 +171,7 @@ func (s *Service) activateManagedAgentCodeSession( return err } inputs := append(historyInputs, queueInputs...) - activated, err := s.db.ActivateManagedAgentCodeSessionWithQueue( + activated, err := s.ActivateManagedAgentCodeSessionWithQueue( ctx, codeSession, items, @@ -186,6 +186,77 @@ func (s *Service) activateManagedAgentCodeSession( } } +// ActivateManagedAgentCodeSessionWithQueue atomically writes startup inputs, +// clears the matched queue snapshot, and activates the Code Session. The +// service owns the cross-resource ordering; each transaction method owns only +// its resource SQL. +func (s *Service) ActivateManagedAgentCodeSessionWithQueue( + ctx context.Context, + codeSession db.CodeSession, + items []db.SessionEventQueueItem, + inputs []db.AppendCodeSessionEventInput, +) (bool, error) { + if s == nil || s.db == nil { + return false, db.ErrNotFound + } + activated := false + err := s.db.WithManagedAgentActivationTx(ctx, func(tx db.ManagedAgentActivationTx) error { + session, err := tx.LockSessionForEvents( + ctx, + codeSession.WorkspaceUUID, + codeSession.SessionExternalID, + ) + if err != nil { + return err + } + current, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) + if err != nil { + return err + } + if !lo.EveryBy(items, func(item db.SessionEventQueueItem) bool { + return item.Event.EventType == "user.message" + }) { + return db.ErrInvalidState + } + matches, err := tx.SessionEventQueueMatches(ctx, session, items) + if err != nil || !matches { + return err + } + for _, input := range inputs { + inserted, duplicate, err := tx.AppendCodeSessionInboundEvent(ctx, current, input) + if err != nil { + return err + } + if duplicate && inserted.CodeSessionExternalID != current.ExternalID { + return db.ErrInvalidState + } + if !duplicate { + current.LastInboundSequenceNum = inserted.SequenceNum + } + } + deleted, err := tx.DeleteSessionEventQueue(ctx, session.UUID) + if err != nil { + return err + } + if deleted != int64(len(items)) { + return db.ErrPreconditionFailed + } + updated, err := tx.ActivateCodeSession(ctx, current.UUID, time.Now().UTC()) + if err != nil { + return err + } + if !updated { + return db.ErrInvalidState + } + activated = true + return nil + }) + if err != nil { + return false, err + } + return activated, nil +} + func (s *Service) listSessionEventsAscending(ctx context.Context, session db.Session) ([]db.SessionEvent, error) { var out []db.SessionEvent var cursor *db.SessionEventPageCursor diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 421e75d6..f96a87bb 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -10,7 +10,6 @@ import ( "time" "github.com/jmoiron/sqlx" - "github.com/samber/lo" ) type CodeSession struct { @@ -279,112 +278,49 @@ func (d *DB) CreateCodeSession(ctx context.Context, input CreateCodeSessionInput }) } -// ActivateManagedAgentCodeSessionWithQueue writes inbound inputs (typically -// historical public events plus startup-queue messages), clears the matched -// startup queue snapshot, and activates the Code Session under the Session lock. -// items is only the queue snapshot used for match/clear; inputs may be longer. -func (d *DB) ActivateManagedAgentCodeSessionWithQueue( +func (tx ManagedAgentActivationTx) LockInitializingCodeSession( ctx context.Context, - codeSession CodeSession, - items []SessionEventQueueItem, - inputs []AppendCodeSessionEventInput, -) (bool, error) { - tx, err := d.sql.BeginTxx(ctx, nil) - if err != nil { - return false, err - } - defer tx.Rollback() - - session, err := getSessionSQLX( - ctx, - tx, - lockSessionForEventsQuery, - sessionLookupArguments(codeSession.WorkspaceUUID, codeSession.SessionExternalID), - ) - if err != nil { - return false, err - } - current, err := getCodeSessionSQLX(ctx, tx, ` + codeSessionUUID string, +) (CodeSession, error) { + return getCodeSessionSQLX(ctx, tx.tx, ` select `+codeSessionColumns()+` from code_sessions - where uuid = CAST(:uuid AS uuid) + where uuid = :uuid and status = 'initializing' and deleted_at is null for update `, map[string]any{ - "uuid": codeSession.UUID, + "uuid": dbUUID(codeSessionUUID), }) - if err != nil { - return false, err - } - // Ownership is enforced by ListSessionEventQueueItems; activation only - // re-checks event type and that the locked queue still matches the snapshot. - if !lo.EveryBy(items, func(item SessionEventQueueItem) bool { - return item.Event.EventType == "user.message" - }) { - return false, ErrInvalidState - } - queueRows, err := listSessionEventQueueIdentityRows(ctx, tx, session, true) - if err != nil { - return false, err - } - if !sessionEventQueueItemsMatch(queueRows, items) { - return false, nil - } +} - for _, input := range inputs { - inserted, duplicate, err := d.appendCodeSessionEventSQLXTx(ctx, tx, current, "inbound", input) - if err != nil { - return false, err - } - if duplicate && inserted.CodeSessionExternalID != current.ExternalID { - return false, ErrInvalidState - } - if !duplicate { - current.LastInboundSequenceNum = inserted.SequenceNum - } - } - deletedResult, err := namedExecContext(ctx, tx, ` - delete from session_event_queue - where session_uuid = CAST(:session_uuid AS uuid) - `, map[string]any{ - "session_uuid": session.UUID, - }) - if err != nil { - return false, err - } - deleted, err := deletedResult.RowsAffected() - if err != nil { - return false, err - } - if deleted != int64(len(items)) { - return false, ErrPreconditionFailed - } +func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvent( + ctx context.Context, + codeSession CodeSession, + input AppendCodeSessionEventInput, +) (CodeSessionEvent, bool, error) { + return tx.database.appendCodeSessionEventSQLXTx(ctx, tx.tx, codeSession, "inbound", input) +} - result, err := namedExecContext(ctx, tx, ` +func (tx ManagedAgentActivationTx) ActivateCodeSession( + ctx context.Context, + codeSessionUUID string, + now time.Time, +) (bool, error) { + updated, err := namedExecRowsAffected(ctx, tx.tx, ` update code_sessions set status = 'active', updated_at = :now - where uuid = CAST(:uuid AS uuid) + where uuid = :uuid and status = 'initializing' and deleted_at is null `, map[string]any{ - "uuid": current.UUID, - "now": time.Now().UTC(), + "uuid": dbUUID(codeSessionUUID), + "now": now, }) if err != nil { return false, err } - updated, err := result.RowsAffected() - if err != nil { - return false, err - } - if updated != 1 { - return false, ErrInvalidState - } - if err := tx.Commit(); err != nil { - return false, err - } - return true, nil + return updated == 1, nil } // codeSessionCredentialContextSelect 查询 code session 的鉴权身份信息。 diff --git a/internal/db/db.go b/internal/db/db.go index e5f1efdb..dce7feb0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -21,20 +21,19 @@ import ( ) var ( - ErrNotFound = platform.ErrNotFound - ErrInvalidState = errors.New("invalid state") - ErrPreconditionFailed = errors.New("precondition failed") - ErrDuplicate = errors.New("duplicate") - ErrVersionConflict = errors.New("version conflict") - ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") - ErrWorkerNotRegistered = errors.New("worker not registered") - ErrWorkerLeaseExpired = errors.New("worker lease expired") - ErrStorageLimitExceeded = errors.New("storage limit exceeded") - ErrStorageUsageUnderflow = errors.New("storage usage underflow") - ErrLimitExceeded = errors.New("limit exceeded") - ErrFileInUse = errors.New("file is in use") - ErrFileReferenceNotFound = errors.New("file reference not found") - ErrSessionStartupMessageConflict = errors.New("session startup message conflict") + ErrNotFound = platform.ErrNotFound + ErrInvalidState = errors.New("invalid state") + ErrPreconditionFailed = errors.New("precondition failed") + ErrDuplicate = errors.New("duplicate") + ErrVersionConflict = errors.New("version conflict") + ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") + ErrWorkerNotRegistered = errors.New("worker not registered") + ErrWorkerLeaseExpired = errors.New("worker lease expired") + ErrStorageLimitExceeded = errors.New("storage limit exceeded") + ErrStorageUsageUnderflow = errors.New("storage usage underflow") + ErrLimitExceeded = errors.New("limit exceeded") + ErrFileInUse = errors.New("file is in use") + ErrFileReferenceNotFound = errors.New("file reference not found") ) type DB struct { diff --git a/internal/db/managed_agent_activation.go b/internal/db/managed_agent_activation.go new file mode 100644 index 00000000..dd818291 --- /dev/null +++ b/internal/db/managed_agent_activation.go @@ -0,0 +1,32 @@ +package db + +import ( + "context" + + "github.com/jmoiron/sqlx" +) + +// ManagedAgentActivationTx exposes the resource-scoped SQL operations used by +// the code-session service to atomically hand off startup events. +type ManagedAgentActivationTx struct { + database *DB + tx *sqlx.Tx +} + +// WithManagedAgentActivationTx owns the database transaction lifecycle while +// leaving activation ordering and business decisions to the code-session service. +func (d *DB) WithManagedAgentActivationTx( + ctx context.Context, + fn func(ManagedAgentActivationTx) error, +) error { + tx, err := d.sql.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := fn(ManagedAgentActivationTx{database: d, tx: tx}); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index 88276d9c..d44697a8 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" + "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/samber/lo" ) @@ -18,6 +19,18 @@ const ( SessionEventDeliveryStartupQueued SessionEventDelivery = "startup_queued" ) +type SessionArchivedError struct{} + +func (*SessionArchivedError) Error() string { + return "session is archived" +} + +type SessionStartupMessageConflictError struct{} + +func (*SessionStartupMessageConflictError) Error() string { + return "session startup message conflict" +} + // SessionEventQueueItem couples one temporary queue identity with its owned // public Session event. type SessionEventQueueItem struct { @@ -28,9 +41,9 @@ type SessionEventQueueItem struct { } type sessionEventQueueIdentityRow struct { - ID int64 `db:"id"` - SessionUUID string `db:"session_uuid"` - SessionEventUUID string `db:"session_event_uuid"` + ID int64 `db:"id"` + SessionUUID uuid.UUID `db:"session_uuid"` + SessionEventUUID uuid.UUID `db:"session_event_uuid"` } // AppendSessionEventsForDelivery keeps the existing delivery path outside the @@ -60,7 +73,7 @@ func (d *DB) AppendSessionEventsForDelivery( return nil, "", err } if session.ArchivedAt != nil { - return nil, "", ErrInvalidState + return nil, "", &SessionArchivedError{} } userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { return event.EventType == "user.message" @@ -74,14 +87,14 @@ func (d *DB) AppendSessionEventsForDelivery( } if shouldQueueForStartup { if len(events) != 1 || userMessageCount != 1 { - return nil, "", ErrSessionStartupMessageConflict + return nil, "", &SessionStartupMessageConflictError{} } pending, err := sessionEventQueueExistsSQLX(ctx, tx, session) if err != nil { return nil, "", err } if pending { - return nil, "", ErrSessionStartupMessageConflict + return nil, "", &SessionStartupMessageConflictError{} } } @@ -98,7 +111,7 @@ func (d *DB) AppendSessionEventsForDelivery( } if len(outcomeEvaluations) > 0 { if _, err := getSessionSQLX(ctx, tx, setSessionOutcomeEvaluationsQuery, map[string]any{ - "workspace_uuid": session.WorkspaceUUID, + "workspace_uuid": dbUUID(session.WorkspaceUUID), "session_external_id": session.ExternalID, "outcome_evaluations": jsonArg(outcomeEvaluations), }); err != nil { @@ -126,7 +139,7 @@ func (d *DB) ListSessionEventQueueItems( event, err := getSessionEventSQLX(ctx, d.sql, ` select `+sessionEventSQLXColumns+` from session_events - where uuid = CAST(:session_event_uuid AS uuid) + where uuid = :session_event_uuid and deleted_at is null `, map[string]any{ "session_event_uuid": row.SessionEventUUID, @@ -135,7 +148,7 @@ func (d *DB) ListSessionEventQueueItems( return nil, fmt.Errorf( "%w: queued event %s does not belong to Session %s", ErrInvalidState, - row.SessionEventUUID, + row.SessionEventUUID.String(), session.ExternalID, ) } @@ -144,8 +157,8 @@ func (d *DB) ListSessionEventQueueItems( } items = append(items, SessionEventQueueItem{ id: row.ID, - sessionUUID: row.SessionUUID, - sessionEventUUID: row.SessionEventUUID, + sessionUUID: row.SessionUUID.String(), + sessionEventUUID: row.SessionEventUUID.String(), Event: event, }) } @@ -161,10 +174,9 @@ func listSessionEventQueueIdentityRows( // session_uuid uniquely identifies the public Session; tenant columns are // written on insert but are not required as query predicates. query := ` - select q.id, CAST(q.session_uuid AS text) as session_uuid, - CAST(q.session_event_uuid AS text) as session_event_uuid + select q.id, q.session_uuid, q.session_event_uuid from session_event_queue q - where q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = :session_uuid order by q.id asc ` if lock { @@ -172,7 +184,7 @@ func listSessionEventQueueIdentityRows( } var rows []sessionEventQueueIdentityRow err := namedSelectContext(ctx, database, &rows, query, map[string]any{ - "session_uuid": session.UUID, + "session_uuid": dbUUID(session.UUID), }) if err != nil { return nil, err @@ -189,14 +201,35 @@ func sessionEventQueueItemsMatch( } for i := range rows { if rows[i].ID != items[i].id || - rows[i].SessionUUID != items[i].sessionUUID || - rows[i].SessionEventUUID != items[i].sessionEventUUID { + rows[i].SessionUUID.String() != items[i].sessionUUID || + rows[i].SessionEventUUID.String() != items[i].sessionEventUUID { return false } } return true } +func (tx ManagedAgentActivationTx) SessionEventQueueMatches( + ctx context.Context, + session Session, + items []SessionEventQueueItem, +) (bool, error) { + rows, err := listSessionEventQueueIdentityRows(ctx, tx.tx, session, true) + if err != nil { + return false, err + } + return sessionEventQueueItemsMatch(rows, items), nil +} + +func (tx ManagedAgentActivationTx) DeleteSessionEventQueue( + ctx context.Context, + sessionUUID string, +) (int64, error) { + return namedExecRowsAffected(ctx, tx.tx, deleteSessionEventQueueQuery, map[string]any{ + "session_uuid": dbUUID(sessionUUID), + }) +} + // shouldQueueUserMessageForStartupSQLX reports whether a user.message should // enter the startup queue. Environment type (for example cloud vs self_hosted) // is intentionally not part of this decision: queueing depends only on whether @@ -211,12 +244,12 @@ func shouldQueueUserMessageForStartupSQLX( err := namedGetContext(ctx, database, &status, ` select status from code_sessions - where session_uuid = CAST(:session_uuid AS uuid) + where session_uuid = :session_uuid and deleted_at is null order by created_at desc, uuid desc limit 1 `, map[string]any{ - "session_uuid": session.UUID, + "session_uuid": dbUUID(session.UUID), }) if err == nil && status != "initializing" { return false, nil @@ -230,16 +263,16 @@ func shouldQueueUserMessageForStartupSQLX( select exists ( select 1 from environment_work ew - where ew.workspace_uuid = CAST(:workspace_uuid AS uuid) - and ew.environment_uuid = CAST(:environment_uuid AS uuid) + where ew.workspace_uuid = :workspace_uuid + and ew.environment_uuid = :environment_uuid and ew.data->>'type' = 'session' and ew.data->>'id' = :session_external_id and ew.state in ('queued', 'starting', 'active') and ew.deleted_at is null ) `, map[string]any{ - "workspace_uuid": session.WorkspaceUUID, - "environment_uuid": session.EnvironmentUUID, + "workspace_uuid": dbUUID(session.WorkspaceUUID), + "environment_uuid": dbUUID(session.EnvironmentUUID), "session_external_id": session.ExternalID, }) if err != nil { @@ -258,10 +291,10 @@ func sessionEventQueueExistsSQLX( select exists ( select 1 from session_event_queue q - where q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = :session_uuid ) `, map[string]any{ - "session_uuid": session.UUID, + "session_uuid": dbUUID(session.UUID), }) return exists, err } @@ -281,16 +314,16 @@ func enqueueSessionEventsSQLXTx( organization_uuid, workspace_uuid, session_uuid, session_event_uuid ) values ( - CAST(:organization_uuid AS uuid), - CAST(:workspace_uuid AS uuid), - CAST(:session_uuid AS uuid), - CAST(:session_event_uuid AS uuid) + :organization_uuid, + :workspace_uuid, + :session_uuid, + :session_event_uuid ) `, map[string]any{ - "organization_uuid": session.OrganizationUUID, - "workspace_uuid": session.WorkspaceUUID, - "session_uuid": session.UUID, - "session_event_uuid": event.UUID, + "organization_uuid": dbUUID(session.OrganizationUUID), + "workspace_uuid": dbUUID(session.WorkspaceUUID), + "session_uuid": dbUUID(session.UUID), + "session_event_uuid": dbUUID(event.UUID), }) if err != nil { return err @@ -300,7 +333,7 @@ func enqueueSessionEventsSQLXTx( return err } if inserted != 1 { - return ErrInvalidState + return fmt.Errorf("enqueue session event: inserted %d rows, want 1", inserted) } } return nil diff --git a/internal/db/sessions.go b/internal/db/sessions.go index b07090eb..3374d60b 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -329,7 +329,7 @@ func (d *DB) DeleteSession(ctx context.Context, workspaceUUID string, externalID if _, err := namedExecContext(ctx, tx, deleteSessionResourcesQuery, arguments); err != nil { return Session{}, err } - arguments["session_uuid"] = session.UUID + arguments["session_uuid"] = dbUUID(session.UUID) if _, err := namedExecContext(ctx, tx, deleteSessionEventQueueQuery, arguments); err != nil { return Session{}, err } diff --git a/internal/db/sessions_sqlx.go b/internal/db/sessions_sqlx.go index 267f0479..a83bd74d 100644 --- a/internal/db/sessions_sqlx.go +++ b/internal/db/sessions_sqlx.go @@ -135,7 +135,7 @@ const ( ` deleteSessionEventQueueQuery = ` delete from session_event_queue q - where q.session_uuid = CAST(:session_uuid AS uuid) + where q.session_uuid = :session_uuid ` stopDeletedSessionEnvironmentWorkQuery = ` update environment_work @@ -353,6 +353,19 @@ func sessionLookupArguments(workspaceUUID string, sessionExternalID string) map[ } } +func (tx ManagedAgentActivationTx) LockSessionForEvents( + ctx context.Context, + workspaceUUID string, + sessionExternalID string, +) (Session, error) { + return getSessionSQLX( + ctx, + tx.tx, + lockSessionForEventsQuery, + sessionLookupArguments(workspaceUUID, sessionExternalID), + ) +} + func getSessionSQLX( ctx context.Context, database sqlxNamedQueryer, diff --git a/internal/sessions/service.go b/internal/sessions/service.go index 96bfb4ee..e3b8c317 100644 --- a/internal/sessions/service.go +++ b/internal/sessions/service.go @@ -620,10 +620,6 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { events, outcomeEvaluations, ) - if errors.Is(err, db.ErrSessionStartupMessageConflict) { - writeStartupMessageConflict(w, r) - return - } if err != nil { h.writeSendEventsPersistenceError(w, r, err, sessionID) return @@ -658,7 +654,13 @@ func (h *Handler) writeSendEventsPersistenceError( err error, sessionID string, ) { - if errors.Is(err, db.ErrInvalidState) { + var startupConflict *db.SessionStartupMessageConflictError + if errors.As(err, &startupConflict) { + writeStartupMessageConflict(w, r) + return + } + var archived *db.SessionArchivedError + if errors.As(err, &archived) { writeBadRequest(w, r, errors.New("archived sessions do not accept new events")) return } diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 26a5453b..5b29161f 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -722,6 +722,7 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) if err != nil { t.Fatalf("load initializing code session: %v", err) } + codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) accepted := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"must be delivered before activation"}]}]}`, defaultTestKey) if len(accepted.Data) != 1 { @@ -729,7 +730,7 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) } acceptedEventID := sessionEventStringField(t, accepted.Data[0], "id") - activated, err := app.db.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, staleItems, nil) + activated, err := codeSessionService.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, staleItems, nil) if err != nil { t.Fatalf("reject stale activation queue snapshot: %v", err) } @@ -744,7 +745,7 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) t.Fatalf("Session event queue items = %#v, want one", items) } - activated, err = app.db.ActivateManagedAgentCodeSessionWithQueue( + activated, err = codeSessionService.ActivateManagedAgentCodeSessionWithQueue( ctx, codeSession, items, @@ -835,6 +836,7 @@ func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { if err != nil { t.Fatalf("load rollback Code Session: %v", err) } + codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) items, err := app.db.ListSessionEventQueueItems(ctx, session) if err != nil { t.Fatalf("load rollback queue items: %v", err) @@ -846,7 +848,7 @@ func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { if err != nil || len(before) != 1 { t.Fatalf("rollback inbound before delivery = (%#v, %v), want initialize", before, err) } - activated, err := app.db.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, items, []db.AppendCodeSessionEventInput{{ + activated, err := codeSessionService.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, items, []db.AppendCodeSessionEventInput{{ ExternalID: before[0].ExternalID, EventType: "user", EventSubtype: "message", From 56afac52b97443f19a6afb3bfc05e58df48aeea9 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Sat, 1 Aug 2026 13:54:35 +0800 Subject: [PATCH 16/23] refactor(sessions): clarify startup queue delivery and activation handoff Use package sentinel errors for archived and startup-conflict cases, and map Send Events failures with inline errors.Is handlers. Rename activation and queue helpers for clearer handoff (shouldEnqueue, QueueMatches, shouldQueueForStartup), batch queue insert/list with session ownership checks, drop the delete-row-count precondition, and keep the design doc aligned with the code entry points. --- .../be/session-startup-message-delivery.md | 42 ++-- .../managed_agent_code_session.go | 119 +++++----- internal/db/db.go | 28 +-- internal/db/deployments.go | 6 +- internal/db/session_event_queue.go | 217 +++++++++++------- internal/sessions/service.go | 41 ++-- tests/sessions_api_test.go | 26 +-- 7 files changed, 255 insertions(+), 224 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index a07d1831..8843d5f5 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -157,7 +157,7 @@ inbound、删 queue 或激活 Code Session。写入时仍落 `organization_uuid` 启动窗口是后端事务内的判断,不是新的公开状态。 -`shouldQueueUserMessageForStartupSQLX` 的规则是: +`shouldQueueForStartup` 的规则是: 1. 查询该 Session 最新且未删除的 Code Session(按 `session_uuid`); 2. 如果 Code Session 存在且状态不是 `initializing`,不进入 startup queue; @@ -195,7 +195,7 @@ normalization 阶段写库。 2. 拒绝 archived Session; 3. batch 包含 `user.message` 时判断启动窗口; 4. 启动窗口只允许 batch 恰好包含一条 `user.message`; -5. queue 已有任何 row 时返回 `SessionStartupMessageConflictError`; +5. queue 已有任何 row 时返回 `ErrSessionStartupMessageConflict`; 6. 写入 `session_events`; 7. 启动窗口内再写入对应 queue row; 8. 有 outcome 变化时在同一事务更新; @@ -257,14 +257,17 @@ sequenceDiagram ### 阶段一:读取历史与 queue 并转换 -Service 在事务外: +`activateManagedAgentCodeSession` 在事务外: -1. `ListSessionEventQueueItems` 读取完整 queue(FIFO)及所属公开事件; -2. 分页 `ListSessionEventsPage`(升序)读取当前 Session 的公开事件历史; -3. 将可转发历史事件转为 inbound inputs,**排除**仍出现在 queue 中的 event UUID - (避免与 queue 交接重复); -4. 再按 queue 顺序转换 queue 中的 `user.message`; -5. 最终 inbound 顺序为:历史(去 queue 重复)→ queue FIFO。 +1. `ListSessionEventQueueItems` 读取完整 queue(FIFO)及所属公开事件,得到 + `queueItems`(`[]SessionEventQueueItem`); +2. `listAllSessionEvents` 分页读取当前 Session 的全部公开事件历史(升序); +3. 从历史中排除仍出现在 queue 中的 event UUID,再经 `convertSessionEventsToInbound` + 转为 `historyInbound`(只保留可转发类型,避免与 queue 交接重复); +4. 再按 queue 顺序对 `queueItems` 中的 `user.message` 调用 + `convertSessionEventToInbound`,得到 `queueInbound`; +5. `inboundInputs = historyInbound + queueInbound`,顺序为:历史(去 queue 重复)→ + queue FIFO。 转换放在事务外,避免在持有 Session 和 Code Session 行锁时执行 JSON/envelope 处理。 queue 仍是启动空窗责任与 cutover 匹配的唯一来源;历史注入对齐旧 @@ -272,25 +275,25 @@ queue 仍是启动空窗责任与 cutover 匹配的唯一来源;历史注入 ### 阶段二:一个事务完成全部交接 -`Service.ActivateManagedAgentCodeSessionWithQueue` 通过 +`Service.CommitManagedAgentCodeSessionActivation` 通过 `DB.WithManagedAgentActivationTx` 定义事务边界并固定执行以下顺序;DB 的事务对象只暴露 Session、queue 和 Code Session 各自的 SQL 操作,不编排跨资源业务流程: ```text 锁 Session → 锁 initializing Code Session -→ 校验 queue item 均为 user.message(Session ownership 已由 List 保证) -→ 锁当前完整 queue -→ 将当前 queue 与事务外 queue 快照逐项比较 -→ 按 inputs 顺序写入全部 inbound(历史 + queue) +→ 校验 queueItems 均为 user.message(Session ownership 已由 List 保证) +→ 锁当前完整 queue(QueueMatches) +→ 将当前 queue 与事务外 queueItems 快照逐项比较 +→ 按 inboundInputs 顺序写入全部 inbound(历史 + queue) → 删除当前 Session 的全部 queue rows → Code Session initializing → active → commit ``` -`items` 仅用于 queue 快照匹配与清空;`inputs` 可长于 `items`。 +`queueItems` 仅用于 queue 快照匹配与清空;`inboundInputs` 可长于 `queueItems`。 快照比较包括 row 数量、queue ID、Session UUID、event UUID 和顺序。如果读取快照以后新消息 -进入 queue,最终事务会发现列表不一致,返回 `activated=false`,且不做任何写入;Service +进入 queue,最终事务会发现列表不一致,返回 `committed=false`,且不做任何写入;Service 重新读取历史与 queue、转换并重试。 inbound 插入复用现有 idempotency key。事务内每写一条新 inbound 都推进当前 Code Session @@ -384,7 +387,6 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | 事件转换失败 | 创建失败;queue 保留 | | 快照与锁定后的完整 queue 不一致 | 激活事务无写入,重新读取并重试 | | 任一 inbound 写入或 sequence 更新失败 | 激活事务整体回滚,queue 保留,状态仍为 `initializing` | -| queue 删除数量不等于快照数量 | 激活事务回滚并返回 precondition error | | active 更新没有恰好影响一行 | 激活事务回滚 | | Code Session 创建流程失败 | 现有 cleanup terminate 未完成的 Code Session | | active 后实时投递失败 | 保持既有行为;公开事件已提交,本设计不增加通用 outbox | @@ -396,11 +398,11 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | Runner prepare 不再读取事件快照 | `Runner.prepareManagedAgentLaunch` | | API 标准化事件与 outcome | `Handler.sendEventsRoute`、`normalizeInputEvent` | | Send 事务和 startup/realtime 分流 | `DB.AppendSessionEventsForDelivery` | -| 启动窗口判断 | `shouldQueueUserMessageForStartupSQLX` | -| queue 写入 | `enqueueSessionEventsSQLXTx` | +| 启动窗口判断 | `shouldQueueForStartup` | +| queue 写入 | `enqueueSessionEventsTx` | | queue 快照及 ownership 加载 | `DB.ListSessionEventQueueItems` | | Code Session 创建和消费循环 | `Service.CreateManagedAgentCodeSession`、`activateManagedAgentCodeSession` | -| 完整 queue 原子交接与激活 | `Service.ActivateManagedAgentCodeSessionWithQueue`、`DB.WithManagedAgentActivationTx` | +| 完整 queue 原子交接与激活 | `Service.CommitManagedAgentCodeSessionActivation`、`DB.WithManagedAgentActivationTx` | | Deployment initial events 入队 | `DB.CreateManualDeploymentRun` | | active 当前 batch 投递 | `Service.QueuePublicSessionEvents` | diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 3400d890..300bbce5 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -127,80 +127,81 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag }, nil } -// activateManagedAgentCodeSession merges public session history with the -// startup queue into inbound, then activates under the same Session lock as send. +// activateManagedAgentCodeSession loads the startup queue and public session +// history outside the activation transaction, converts them to inbound inputs, +// then commits activation under the same Session row lock used by Send Events. // -// Order: historical forwardable session_events (excluding UUIDs still in the -// queue) first, then queue items in FIFO order. Queue remains the sole source of -// startup-window responsibility and cutover matching. +// Inbound order after initialize: forwardable session history excluding events +// still referenced by the startup queue, then queue items in FIFO order. The +// queue snapshot is the only cutover signal: if it changes before commit, the +// loop reloads and retries. func (s *Service) activateManagedAgentCodeSession( ctx context.Context, session db.Session, codeSession db.CodeSession, ) error { for { - items, err := s.db.ListSessionEventQueueItems(ctx, session) + queueItems, err := s.db.ListSessionEventQueueItems(ctx, session) if err != nil { return err } - history, err := s.listSessionEventsAscending(ctx, session) + sessionEvents, err := s.listAllSessionEvents(ctx, session) if err != nil { return err } - queuedUUIDs := lo.SliceToMap(items, func(item db.SessionEventQueueItem) (string, struct{}) { + queuedEventUUIDs := lo.SliceToMap(queueItems, func(item db.SessionEventQueueItem) (string, struct{}) { return item.Event.UUID, struct{}{} }) - historyOnly := lo.Filter(history, func(event db.SessionEvent, _ int) bool { - _, queued := queuedUUIDs[event.UUID] + sessionEventsOutsideQueue := lo.Filter(sessionEvents, func(event db.SessionEvent, _ int) bool { + _, queued := queuedEventUUIDs[event.UUID] return !queued }) - historyInputs, err := s.inboundInputsFromPublicSessionEvents(codeSession.ExternalID, historyOnly) + historyInbound, err := s.convertSessionEventsToInbound(codeSession.ExternalID, sessionEventsOutsideQueue) if err != nil { return err } - queueInputs, err := lo.MapErr(items, func(item db.SessionEventQueueItem, _ int) (db.AppendCodeSessionEventInput, error) { + queueInbound, err := lo.MapErr(queueItems, func(item db.SessionEventQueueItem, _ int) (db.AppendCodeSessionEventInput, error) { if item.Event.EventType != "user.message" { return db.AppendCodeSessionEventInput{}, fmt.Errorf( "%w: session event queue contains a non-user message", db.ErrInvalidState, ) } - return s.inboundInputFromPublicSessionEvent(codeSession.ExternalID, item.Event) + return s.convertSessionEventToInbound(codeSession.ExternalID, item.Event) }) if err != nil { return err } - inputs := append(historyInputs, queueInputs...) - activated, err := s.ActivateManagedAgentCodeSessionWithQueue( + inboundInputs := append(historyInbound, queueInbound...) + committed, err := s.CommitManagedAgentCodeSessionActivation( ctx, codeSession, - items, - inputs, + queueItems, + inboundInputs, ) if err != nil { return err } - if activated { + if committed { return nil } } } -// ActivateManagedAgentCodeSessionWithQueue atomically writes startup inputs, -// clears the matched queue snapshot, and activates the Code Session. The -// service owns the cross-resource ordering; each transaction method owns only -// its resource SQL. -func (s *Service) ActivateManagedAgentCodeSessionWithQueue( +// CommitManagedAgentCodeSessionActivation writes inbound inputs, clears the +// matched startup queue snapshot, and marks the Code Session active in one +// transaction. It returns committed=false when the locked queue no longer +// matches queueItems so the caller can reload and retry without partial writes. +func (s *Service) CommitManagedAgentCodeSessionActivation( ctx context.Context, codeSession db.CodeSession, - items []db.SessionEventQueueItem, - inputs []db.AppendCodeSessionEventInput, -) (bool, error) { + queueItems []db.SessionEventQueueItem, + inboundInputs []db.AppendCodeSessionEventInput, +) (committed bool, err error) { if s == nil || s.db == nil { return false, db.ErrNotFound } - activated := false - err := s.db.WithManagedAgentActivationTx(ctx, func(tx db.ManagedAgentActivationTx) error { + err = s.db.WithManagedAgentActivationTx(ctx, func(tx db.ManagedAgentActivationTx) error { session, err := tx.LockSessionForEvents( ctx, codeSession.WorkspaceUUID, @@ -209,59 +210,57 @@ func (s *Service) ActivateManagedAgentCodeSessionWithQueue( if err != nil { return err } - current, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) + codeSession, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) if err != nil { return err } - if !lo.EveryBy(items, func(item db.SessionEventQueueItem) bool { + if !lo.EveryBy(queueItems, func(item db.SessionEventQueueItem) bool { return item.Event.EventType == "user.message" }) { return db.ErrInvalidState } - matches, err := tx.SessionEventQueueMatches(ctx, session, items) - if err != nil || !matches { + queueMatches, err := tx.QueueMatches(ctx, session, queueItems) + if err != nil || !queueMatches { return err } - for _, input := range inputs { - inserted, duplicate, err := tx.AppendCodeSessionInboundEvent(ctx, current, input) + for _, inbound := range inboundInputs { + inserted, duplicate, err := tx.AppendCodeSessionInboundEvent(ctx, codeSession, inbound) if err != nil { return err } - if duplicate && inserted.CodeSessionExternalID != current.ExternalID { + if duplicate && inserted.CodeSessionExternalID != codeSession.ExternalID { return db.ErrInvalidState } if !duplicate { - current.LastInboundSequenceNum = inserted.SequenceNum + codeSession.LastInboundSequenceNum = inserted.SequenceNum } } - deleted, err := tx.DeleteSessionEventQueue(ctx, session.UUID) - if err != nil { + if err := tx.DeleteSessionEventQueue(ctx, session.UUID); err != nil { return err } - if deleted != int64(len(items)) { - return db.ErrPreconditionFailed - } - updated, err := tx.ActivateCodeSession(ctx, current.UUID, time.Now().UTC()) + statusUpdated, err := tx.ActivateCodeSession(ctx, codeSession.UUID, time.Now().UTC()) if err != nil { return err } - if !updated { + if !statusUpdated { return db.ErrInvalidState } - activated = true + committed = true return nil }) if err != nil { return false, err } - return activated, nil + return committed, nil } -func (s *Service) listSessionEventsAscending(ctx context.Context, session db.Session) ([]db.SessionEvent, error) { - var out []db.SessionEvent +// listAllSessionEvents returns every non-deleted public session event in +// ascending creation order by paging through ListSessionEventsPage. +func (s *Service) listAllSessionEvents(ctx context.Context, session db.Session) ([]db.SessionEvent, error) { + var all []db.SessionEvent var cursor *db.SessionEventPageCursor for { - events, hasMore, err := s.db.ListSessionEventsPage(ctx, db.ListSessionEventsPageParams{ + page, hasMore, err := s.db.ListSessionEventsPage(ctx, db.ListSessionEventsPageParams{ WorkspaceUUID: session.WorkspaceUUID, SessionExternalID: session.ExternalID, Limit: 100, @@ -271,34 +270,38 @@ func (s *Service) listSessionEventsAscending(ctx context.Context, session db.Ses if err != nil { return nil, err } - out = append(out, events...) - if !hasMore || len(events) == 0 { - return out, nil + all = append(all, page...) + if !hasMore || len(page) == 0 { + return all, nil } - last := events[len(events)-1] + last := page[len(page)-1] cursor = &db.SessionEventPageCursor{CreatedAt: last.CreatedAt, UUID: last.UUID} } } -func (s *Service) inboundInputsFromPublicSessionEvents( +// convertSessionEventsToInbound keeps forwardable session events and converts +// each into an inbound write input for the given Code Session. +func (s *Service) convertSessionEventsToInbound( codeSessionID string, events []db.SessionEvent, ) ([]db.AppendCodeSessionEventInput, error) { - inputs := make([]db.AppendCodeSessionEventInput, 0, len(events)) + inboundInputs := make([]db.AppendCodeSessionEventInput, 0, len(events)) for _, event := range events { if !forwardPublicEventToWorker(event.EventType) { continue } - input, err := s.inboundInputFromPublicSessionEvent(codeSessionID, event) + inbound, err := s.convertSessionEventToInbound(codeSessionID, event) if err != nil { return nil, err } - inputs = append(inputs, input) + inboundInputs = append(inboundInputs, inbound) } - return inputs, nil + return inboundInputs, nil } -func (s *Service) inboundInputFromPublicSessionEvent( +// convertSessionEventToInbound maps one public session event payload into a +// Code Session inbound append input. +func (s *Service) convertSessionEventToInbound( codeSessionID string, event db.SessionEvent, ) (db.AppendCodeSessionEventInput, error) { diff --git a/internal/db/db.go b/internal/db/db.go index dce7feb0..b0b800cf 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -21,19 +21,21 @@ import ( ) var ( - ErrNotFound = platform.ErrNotFound - ErrInvalidState = errors.New("invalid state") - ErrPreconditionFailed = errors.New("precondition failed") - ErrDuplicate = errors.New("duplicate") - ErrVersionConflict = errors.New("version conflict") - ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") - ErrWorkerNotRegistered = errors.New("worker not registered") - ErrWorkerLeaseExpired = errors.New("worker lease expired") - ErrStorageLimitExceeded = errors.New("storage limit exceeded") - ErrStorageUsageUnderflow = errors.New("storage usage underflow") - ErrLimitExceeded = errors.New("limit exceeded") - ErrFileInUse = errors.New("file is in use") - ErrFileReferenceNotFound = errors.New("file reference not found") + ErrNotFound = platform.ErrNotFound + ErrInvalidState = errors.New("invalid state") + ErrPreconditionFailed = errors.New("precondition failed") + ErrDuplicate = errors.New("duplicate") + ErrVersionConflict = errors.New("version conflict") + ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") + ErrWorkerNotRegistered = errors.New("worker not registered") + ErrWorkerLeaseExpired = errors.New("worker lease expired") + ErrStorageLimitExceeded = errors.New("storage limit exceeded") + ErrStorageUsageUnderflow = errors.New("storage usage underflow") + ErrLimitExceeded = errors.New("limit exceeded") + ErrFileInUse = errors.New("file is in use") + ErrFileReferenceNotFound = errors.New("file reference not found") + ErrSessionArchived = errors.New("session is archived") + ErrSessionStartupMessageConflict = errors.New("session startup message conflict") ) type DB struct { diff --git a/internal/db/deployments.go b/internal/db/deployments.go index f553ee6d..505c72d6 100644 --- a/internal/db/deployments.go +++ b/internal/db/deployments.go @@ -280,12 +280,12 @@ func (d *DB) CreateManualDeploymentRun(ctx context.Context, input CreateManualDe if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } - shouldQueueForStartup, err := shouldQueueUserMessageForStartupSQLX(ctx, tx, session) + shouldEnqueue, err := shouldQueueForStartup(ctx, tx, session) if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } - if shouldQueueForStartup { - if err := enqueueSessionEventsSQLXTx(ctx, tx, session, events); err != nil { + if shouldEnqueue { + if err := enqueueSessionEventsTx(ctx, tx, session, events); err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } } diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index d44697a8..ec9fd32e 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -19,24 +19,12 @@ const ( SessionEventDeliveryStartupQueued SessionEventDelivery = "startup_queued" ) -type SessionArchivedError struct{} - -func (*SessionArchivedError) Error() string { - return "session is archived" -} - -type SessionStartupMessageConflictError struct{} - -func (*SessionStartupMessageConflictError) Error() string { - return "session startup message conflict" -} - // SessionEventQueueItem couples one temporary queue identity with its owned // public Session event. type SessionEventQueueItem struct { id int64 - sessionUUID string - sessionEventUUID string + sessionUUID uuid.UUID + sessionEventUUID uuid.UUID Event SessionEvent } @@ -46,6 +34,13 @@ type sessionEventQueueIdentityRow struct { SessionEventUUID uuid.UUID `db:"session_event_uuid"` } +type sessionEventQueueInsertRow struct { + OrganizationUUID uuid.UUID `db:"organization_uuid"` + WorkspaceUUID uuid.UUID `db:"workspace_uuid"` + SessionUUID uuid.UUID `db:"session_uuid"` + SessionEventUUID uuid.UUID `db:"session_event_uuid"` +} + // AppendSessionEventsForDelivery keeps the existing delivery path outside the // managed-agent startup window. During startup it accepts exactly one // user.message with an empty queue and records the public event and temporary @@ -73,28 +68,28 @@ func (d *DB) AppendSessionEventsForDelivery( return nil, "", err } if session.ArchivedAt != nil { - return nil, "", &SessionArchivedError{} + return nil, "", ErrSessionArchived } userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { return event.EventType == "user.message" }) - shouldQueueForStartup := false + shouldEnqueue := false if userMessageCount > 0 { - shouldQueueForStartup, err = shouldQueueUserMessageForStartupSQLX(ctx, tx, session) + shouldEnqueue, err = shouldQueueForStartup(ctx, tx, session) if err != nil { return nil, "", err } } - if shouldQueueForStartup { + if shouldEnqueue { if len(events) != 1 || userMessageCount != 1 { - return nil, "", &SessionStartupMessageConflictError{} + return nil, "", ErrSessionStartupMessageConflict } - pending, err := sessionEventQueueExistsSQLX(ctx, tx, session) + hasQueuedEvents, err := sessionEventQueueExists(ctx, tx, session) if err != nil { return nil, "", err } - if pending { - return nil, "", &SessionStartupMessageConflictError{} + if hasQueuedEvents { + return nil, "", ErrSessionStartupMessageConflict } } @@ -103,8 +98,8 @@ func (d *DB) AppendSessionEventsForDelivery( return nil, "", err } delivery := SessionEventDeliveryRealtime - if shouldQueueForStartup { - if err := enqueueSessionEventsSQLXTx(ctx, tx, session, created); err != nil { + if shouldEnqueue { + if err := enqueueSessionEventsTx(ctx, tx, session, created); err != nil { return nil, "", err } delivery = SessionEventDeliveryStartupQueued @@ -130,21 +125,23 @@ func (d *DB) ListSessionEventQueueItems( ctx context.Context, session Session, ) ([]SessionEventQueueItem, error) { - rows, err := listSessionEventQueueIdentityRows(ctx, d.sql, session, false) + identityRows, err := listSessionEventQueueIdentityRows(ctx, d.sql, session, false) if err != nil { return nil, err } - items := make([]SessionEventQueueItem, 0, len(rows)) - for _, row := range rows { - event, err := getSessionEventSQLX(ctx, d.sql, ` - select `+sessionEventSQLXColumns+` - from session_events - where uuid = :session_event_uuid - and deleted_at is null - `, map[string]any{ - "session_event_uuid": row.SessionEventUUID, - }) - if errors.Is(err, ErrNotFound) { + if len(identityRows) == 0 { + return nil, nil + } + + eventsByUUID, err := sessionEventsByUUIDs(ctx, d.sql, session, identityRows) + if err != nil { + return nil, err + } + + queueItems := make([]SessionEventQueueItem, 0, len(identityRows)) + for _, row := range identityRows { + event, ok := eventsByUUID[row.SessionEventUUID.String()] + if !ok { return nil, fmt.Errorf( "%w: queued event %s does not belong to Session %s", ErrInvalidState, @@ -152,17 +149,44 @@ func (d *DB) ListSessionEventQueueItems( session.ExternalID, ) } - if err != nil { - return nil, err - } - items = append(items, SessionEventQueueItem{ + queueItems = append(queueItems, SessionEventQueueItem{ id: row.ID, - sessionUUID: row.SessionUUID.String(), - sessionEventUUID: row.SessionEventUUID.String(), + sessionUUID: row.SessionUUID, + sessionEventUUID: row.SessionEventUUID, Event: event, }) } - return items, nil + return queueItems, nil +} + +func sessionEventsByUUIDs( + ctx context.Context, + database sqlxNamedQueryer, + session Session, + identityRows []sessionEventQueueIdentityRow, +) (map[string]SessionEvent, error) { + eventUUIDs := make([]string, len(identityRows)) + for i, row := range identityRows { + eventUUIDs[i] = row.SessionEventUUID.String() + } + events, err := listSessionEventsSQLX(ctx, database, ` + select `+sessionEventSQLXColumns+` + from session_events + where uuid = any(:session_event_uuids) + and session_uuid = :session_uuid + and deleted_at is null + `, map[string]any{ + "session_event_uuids": eventUUIDs, + "session_uuid": dbUUID(session.UUID), + }) + if err != nil { + return nil, err + } + byUUID := make(map[string]SessionEvent, len(events)) + for _, event := range events { + byUUID[event.UUID] = event + } + return byUUID, nil } func listSessionEventQueueIdentityRows( @@ -192,50 +216,52 @@ func listSessionEventQueueIdentityRows( return rows, nil } -func sessionEventQueueItemsMatch( +func queueItemsMatch( rows []sessionEventQueueIdentityRow, - items []SessionEventQueueItem, + queueItems []SessionEventQueueItem, ) bool { - if len(rows) != len(items) { + if len(rows) != len(queueItems) { return false } for i := range rows { - if rows[i].ID != items[i].id || - rows[i].SessionUUID.String() != items[i].sessionUUID || - rows[i].SessionEventUUID.String() != items[i].sessionEventUUID { + if rows[i].ID != queueItems[i].id || + rows[i].SessionUUID != queueItems[i].sessionUUID || + rows[i].SessionEventUUID != queueItems[i].sessionEventUUID { return false } } return true } -func (tx ManagedAgentActivationTx) SessionEventQueueMatches( +// QueueMatches reports whether the locked startup queue still matches the +// caller's queueItems snapshot (count, order, and identity fields). +func (tx ManagedAgentActivationTx) QueueMatches( ctx context.Context, session Session, - items []SessionEventQueueItem, + queueItems []SessionEventQueueItem, ) (bool, error) { rows, err := listSessionEventQueueIdentityRows(ctx, tx.tx, session, true) if err != nil { return false, err } - return sessionEventQueueItemsMatch(rows, items), nil + return queueItemsMatch(rows, queueItems), nil } func (tx ManagedAgentActivationTx) DeleteSessionEventQueue( ctx context.Context, sessionUUID string, -) (int64, error) { - return namedExecRowsAffected(ctx, tx.tx, deleteSessionEventQueueQuery, map[string]any{ +) error { + _, err := namedExecRowsAffected(ctx, tx.tx, deleteSessionEventQueueQuery, map[string]any{ "session_uuid": dbUUID(sessionUUID), }) + return err } -// shouldQueueUserMessageForStartupSQLX reports whether a user.message should -// enter the startup queue. Environment type (for example cloud vs self_hosted) -// is intentionally not part of this decision: queueing depends only on whether -// the Session still has no active Code Session and still has session-scoped -// environment work in flight. -func shouldQueueUserMessageForStartupSQLX( +// shouldQueueForStartup reports whether a user.message should enter the startup +// queue. Environment type is intentionally not part of this decision: queueing +// depends only on whether the Session still has no active Code Session and still +// has session-scoped environment work in flight. +func shouldQueueForStartup( ctx context.Context, database sqlxNamedQueryer, session Session, @@ -281,7 +307,7 @@ func shouldQueueUserMessageForStartupSQLX( return startupWorkExists, nil } -func sessionEventQueueExistsSQLX( +func sessionEventQueueExists( ctx context.Context, database sqlxNamedQueryer, session Session, @@ -299,42 +325,55 @@ func sessionEventQueueExistsSQLX( return exists, err } -func enqueueSessionEventsSQLXTx( +func enqueueSessionEventsTx( ctx context.Context, tx *sqlx.Tx, session Session, events []SessionEvent, ) error { - // todo batch - for _, event := range lo.Filter(events, func(event SessionEvent, _ int) bool { - return event.EventType == "user.message" - }) { - result, err := namedExecContext(ctx, tx, ` - insert into session_event_queue ( - organization_uuid, workspace_uuid, session_uuid, session_event_uuid - ) - values ( - :organization_uuid, - :workspace_uuid, - :session_uuid, - :session_event_uuid - ) - `, map[string]any{ - "organization_uuid": dbUUID(session.OrganizationUUID), - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "session_uuid": dbUUID(session.UUID), - "session_event_uuid": dbUUID(event.UUID), - }) - if err != nil { - return err + organizationUUID, err := parseDBUUID("organization_uuid", session.OrganizationUUID) + if err != nil { + return err + } + workspaceUUID, err := parseDBUUID("workspace_uuid", session.WorkspaceUUID) + if err != nil { + return err + } + sessionUUID, err := parseDBUUID("session_uuid", session.UUID) + if err != nil { + return err + } + + rows := make([]sessionEventQueueInsertRow, 0, len(events)) + for _, event := range events { + if event.EventType != "user.message" { + continue } - inserted, err := result.RowsAffected() + eventUUID, err := parseDBUUID("session_event_uuid", event.UUID) if err != nil { return err } - if inserted != 1 { - return fmt.Errorf("enqueue session event: inserted %d rows, want 1", inserted) - } + rows = append(rows, sessionEventQueueInsertRow{ + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + SessionUUID: sessionUUID, + SessionEventUUID: eventUUID, + }) + } + if len(rows) == 0 { + return nil } - return nil + + _, err = tx.NamedExecContext(ctx, ` + insert into session_event_queue ( + organization_uuid, workspace_uuid, session_uuid, session_event_uuid + ) + values ( + :organization_uuid, + :workspace_uuid, + :session_uuid, + :session_event_uuid + ) + `, rows) + return err } diff --git a/internal/sessions/service.go b/internal/sessions/service.go index e3b8c317..05e550d5 100644 --- a/internal/sessions/service.go +++ b/internal/sessions/service.go @@ -621,7 +621,19 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { outcomeEvaluations, ) if err != nil { - h.writeSendEventsPersistenceError(w, r, err, sessionID) + if errors.Is(err, db.ErrSessionStartupMessageConflict) { + httpapi.WriteError(w, r, httpapi.NewError( + http.StatusConflict, + "conflict_error", + "Only one user message can be accepted while the session starts", + )) + return + } + if errors.Is(err, db.ErrSessionArchived) { + writeBadRequest(w, r, errors.New("archived sessions do not accept new events")) + return + } + h.writeSessionLoadError(w, r, err, sessionID) return } for _, event := range created { @@ -648,33 +660,6 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { httpapi.WriteJSON(w, http.StatusOK, sendEventsResponse{Data: data}) } -func (h *Handler) writeSendEventsPersistenceError( - w http.ResponseWriter, - r *http.Request, - err error, - sessionID string, -) { - var startupConflict *db.SessionStartupMessageConflictError - if errors.As(err, &startupConflict) { - writeStartupMessageConflict(w, r) - return - } - var archived *db.SessionArchivedError - if errors.As(err, &archived) { - writeBadRequest(w, r, errors.New("archived sessions do not accept new events")) - return - } - h.writeSessionLoadError(w, r, err, sessionID) -} - -func writeStartupMessageConflict(w http.ResponseWriter, r *http.Request) { - httpapi.WriteError(w, r, httpapi.NewError( - http.StatusConflict, - "conflict_error", - "Only one user message can be accepted while the session starts", - )) -} - func (h *Handler) addResourceRoute(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "session_id") if h.isOfficialSDKFixtureSession(r, sessionID) { diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 5b29161f..0c256c85 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -730,25 +730,25 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) } acceptedEventID := sessionEventStringField(t, accepted.Data[0], "id") - activated, err := codeSessionService.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, staleItems, nil) + committed, err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession, staleItems, nil) if err != nil { t.Fatalf("reject stale activation queue snapshot: %v", err) } - if activated { + if committed { t.Fatal("activation succeeded with a stale empty queue snapshot") } - items, err := app.db.ListSessionEventQueueItems(ctx, sessionRecord) + queueItems, err := app.db.ListSessionEventQueueItems(ctx, sessionRecord) if err != nil { t.Fatalf("list Session event queue for activation: %v", err) } - if len(items) != 1 { - t.Fatalf("Session event queue items = %#v, want one", items) + if len(queueItems) != 1 { + t.Fatalf("Session event queue items = %#v, want one", queueItems) } - activated, err = codeSessionService.ActivateManagedAgentCodeSessionWithQueue( + committed, err = codeSessionService.CommitManagedAgentCodeSessionActivation( ctx, codeSession, - items, + queueItems, []db.AppendCodeSessionEventInput{{ ExternalID: "csev_activation_" + strings.TrimPrefix(codeSessionID, "cse_"), EventType: "user", @@ -764,7 +764,7 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) if err != nil { t.Fatalf("activate with queued session event: %v", err) } - if !activated { + if !committed { t.Fatal("activation did not commit the queued session event") } codeSession, err = app.db.GetCodeSession(ctx, codeSessionID) @@ -837,18 +837,18 @@ func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { t.Fatalf("load rollback Code Session: %v", err) } codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) - items, err := app.db.ListSessionEventQueueItems(ctx, session) + queueItems, err := app.db.ListSessionEventQueueItems(ctx, session) if err != nil { t.Fatalf("load rollback queue items: %v", err) } - if len(items) != 1 { - t.Fatalf("rollback queue items = %#v, want one", items) + if len(queueItems) != 1 { + t.Fatalf("rollback queue items = %#v, want one", queueItems) } before, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) if err != nil || len(before) != 1 { t.Fatalf("rollback inbound before delivery = (%#v, %v), want initialize", before, err) } - activated, err := codeSessionService.ActivateManagedAgentCodeSessionWithQueue(ctx, codeSession, items, []db.AppendCodeSessionEventInput{{ + committed, err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession, queueItems, []db.AppendCodeSessionEventInput{{ ExternalID: before[0].ExternalID, EventType: "user", EventSubtype: "message", @@ -859,7 +859,7 @@ func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { Source: "public-session", CreatedAt: time.Now().UTC(), }}) - if err == nil || activated { + if err == nil || committed { t.Fatal("queue delivery with duplicate inbound external ID succeeded") } if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 1 { From 3fa3cbee3ef721011382862cb86bd194e900413e Mon Sep 17 00:00:00 2001 From: xgxgx Date: Sat, 1 Aug 2026 16:37:31 +0800 Subject: [PATCH 17/23] fix(sessions): atomically replay activation history --- .../be/session-startup-message-delivery.md | 96 +++++----- .../managed_agent_code_session.go | 171 ++++------------- internal/db/code_sessions.go | 179 +++++++++++++++++- internal/db/code_sessions_sqlx.go | 24 +++ internal/db/managed_agent_activation.go | 5 +- internal/db/session_event_queue.go | 104 +++++----- tests/environments_runner_cloud_test.go | 14 +- tests/sessions_api_test.go | 148 ++++++++------- 8 files changed, 410 insertions(+), 331 deletions(-) diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 8843d5f5..a72f5d51 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -38,9 +38,9 @@ Code Session 激活事务中与 queue 清理一起原子写入 inbound,保证 | --- | --- | --- | | 启动期增量来源 | Runner prepare 时读取一次含历史的 `InitialEvents` 快照 | Send 启动窗写入 queue;激活时再合并历史 `session_events` | | prepare 后到达的消息 | 不在旧快照中 | 与 `session_events` 同事务进入 queue | -| Code Session 创建 | 使用 prepare 阶段快照写 inbound | 激活时:历史 `session_events` + queue → inbound | +| Code Session 创建 | 使用 prepare 阶段快照写 inbound | 锁定 Session 后读取完整 queue 与历史并原子激活 | | 消息责任 | API 200 后没有跨启动流程的持久化责任 | queue row 持有到最终激活事务提交 | -| 激活条件 | 快照处理完成后继续启动 | inbound、清空 queue、切 `active` 一次提交 | +| 激活条件 | 快照处理完成后继续启动 | 锁内读取 history、写 inbound、清空 queue、切 `active` 一次提交 | | active 后发送 | 实时投递当前 batch | 保持不变 | 变化的核心不是“创建 Code Session 时扫描更多历史”,而是把投递责任前移到接收消息的事务: @@ -104,20 +104,16 @@ sequenceDiagram Runner->>CS: INSERT status=initializing Runner->>Inbound: INSERT initialize (sequence=1) - Runner->>Queue: 读取完整 queue 快照(FIFO) - Queue-->>Runner: queue items - Runner->>Events: 按 Session ownership 加载 event payloads - Runner->>Runner: 转换为 inbound inputs - rect rgb(238, 247, 255) Note over Session,CS: 最终激活事务 Runner->>Session: SELECT FOR UPDATE Runner->>CS: SELECT initializing FOR UPDATE Runner->>Queue: SELECT full queue FOR UPDATE - Runner->>Runner: 与事务外快照逐项比较 - loop queue.id 顺序 - Runner->>Inbound: INSERT inbound + Runner->>Events: SELECT complete history ORDER BY created_at, id + loop stable history order + Runner->>Runner: 过滤并转换可转发事件 end + Runner->>Inbound: 分块批量 INSERT inbound Runner->>Queue: DELETE all rows Runner->>CS: UPDATE initializing → active Runner->>Runner: COMMIT @@ -251,29 +247,12 @@ sequenceDiagram 1. 创建状态为 `initializing` 的 Code Session; 2. 写入 sequence 1 的 `initialize` inbound; -3. 调用 `activateManagedAgentCodeSession`:合并历史 `session_events` 与 queue 后激活; +3. 调用 `CommitManagedAgentCodeSessionActivation`,在最终激活事务内读取 queue 与完整 + `session_events`,按稳定顺序构造 inbound 并激活; 4. 激活成功后才继续签发并返回 runtime 启动信息; 5. 中途失败时,现有 defer cleanup 将未完成的 Code Session terminate。 -### 阶段一:读取历史与 queue 并转换 - -`activateManagedAgentCodeSession` 在事务外: - -1. `ListSessionEventQueueItems` 读取完整 queue(FIFO)及所属公开事件,得到 - `queueItems`(`[]SessionEventQueueItem`); -2. `listAllSessionEvents` 分页读取当前 Session 的全部公开事件历史(升序); -3. 从历史中排除仍出现在 queue 中的 event UUID,再经 `convertSessionEventsToInbound` - 转为 `historyInbound`(只保留可转发类型,避免与 queue 交接重复); -4. 再按 queue 顺序对 `queueItems` 中的 `user.message` 调用 - `convertSessionEventToInbound`,得到 `queueInbound`; -5. `inboundInputs = historyInbound + queueInbound`,顺序为:历史(去 queue 重复)→ - queue FIFO。 - -转换放在事务外,避免在持有 Session 和 Code Session 行锁时执行 JSON/envelope 处理。 -queue 仍是启动空窗责任与 cutover 匹配的唯一来源;历史注入对齐旧 -`InitialEvents` 全量扫描,保证再起 Code Session 时能带上已有多轮对话。 - -### 阶段二:一个事务完成全部交接 +### 一个事务完成读取、交接与激活 `Service.CommitManagedAgentCodeSessionActivation` 通过 `DB.WithManagedAgentActivationTx` 定义事务边界并固定执行以下顺序;DB 的事务对象只暴露 @@ -282,22 +261,28 @@ Session、queue 和 Code Session 各自的 SQL 操作,不编排跨资源业务 ```text 锁 Session → 锁 initializing Code Session -→ 校验 queueItems 均为 user.message(Session ownership 已由 List 保证) -→ 锁当前完整 queue(QueueMatches) -→ 将当前 queue 与事务外 queueItems 快照逐项比较 -→ 按 inboundInputs 顺序写入全部 inbound(历史 + queue) +→ 读取并锁定完整 queue,校验均为 user.message 且属于当前 Session +→ 读取当前 Session 的完整公开历史(created_at asc, id asc) +→ 按该稳定顺序过滤、转换可转发 inbound +→ 批量检查幂等键、分配连续 sequence,并按固定大小分块 INSERT +→ 一次更新 Code Session 的 last inbound sequence → 删除当前 Session 的全部 queue rows → Code Session initializing → active → commit ``` -`queueItems` 仅用于 queue 快照匹配与清空;`inboundInputs` 可长于 `queueItems`。 -快照比较包括 row 数量、queue ID、Session UUID、event UUID 和顺序。如果读取快照以后新消息 -进入 queue,最终事务会发现列表不一致,返回 `committed=false`,且不做任何写入;Service -重新读取历史与 queue、转换并重试。 +queue 只承担首条启动 `user.message` 的临时投递责任;queue 中的消息已经属于公开历史, +不会再单独追加。激活先取得 Send Events 使用的同一条 Session 行锁,再读取 queue 与历史, +因此不需要事务外快照、UUID match 或重试循环。 + +`created_at` 保持既有历史时间语义;同一 batch 共用时间戳时,以仅限数据库内部排序的 identity +`id` 保持原始写入顺序。这样 Deployment initial messages、同 batch 消息以及 +`user.message → user.interrupt` 都使用同一个稳定顺序来源。 -inbound 插入复用现有 idempotency key。事务内每写一条新 inbound 都推进当前 Code Session -的 sequence,保证历史与 Deployment initial messages 保持稳定顺序。 +inbound 插入复用现有 idempotency key。事务内先批量加载已存在的幂等键,只为新事件按 +history 输入顺序分配连续 sequence;随后每 500 条执行一次批量 INSERT,并在全部写入成功后 +只更新一次 Code Session 的 last inbound sequence。这样既保持历史与 Deployment initial +messages 的稳定顺序,也避免持有 Session 锁时逐事件执行查询、插入和 sequence 更新。 ## 激活 cutover 的并发语义 @@ -309,6 +294,7 @@ sequenceDiagram participant Send as Send transaction participant Activate as Activation transaction participant Session as sessions row + participant Events as session_events participant Queue as session_event_queue participant CS as code_sessions participant Inbound as code_session_inbound_events @@ -316,18 +302,20 @@ sequenceDiagram alt Send 先获得 Session 锁 Send->>Session: SELECT FOR UPDATE Activate->>Session: 等待 - Send->>Queue: INSERT event reference + Send->>Events: INSERT public event + Send->>Queue: user.message 时 INSERT reference Send->>Send: COMMIT Activate->>Session: 获得锁 - Activate->>Queue: 快照不一致 - Activate-->>Activate: rollback and retry - Activate->>Inbound: 交接包含新消息的完整 queue + Activate->>Queue: 读取并锁定当前完整 queue + Activate->>Events: 读取包含已提交事件的完整历史 + Activate->>Inbound: 按稳定历史顺序交接 Activate->>Queue: DELETE all Activate->>CS: UPDATE active + COMMIT else Activate 先获得 Session 锁 Activate->>Session: SELECT FOR UPDATE Send->>Session: 等待 - Activate->>Queue: 锁定并确认完整 queue + Activate->>Queue: 读取并锁定完整 queue + Activate->>Events: 读取当前完整历史 Activate->>Inbound: INSERT all startup inputs Activate->>Queue: DELETE all Activate->>CS: UPDATE active + COMMIT @@ -350,7 +338,7 @@ Deployment 创建 Session 时,Session、initial events、queue 和 Deployment 4. 非 `user.message` 保留为公开事件,但不进入这个窄 queue; 5. 任一步失败都回滚整个 Deployment 创建事务。 -Code Session 激活事务一次性交接完整 queue,因此 inbound 顺序为: +Code Session 激活事务按稳定历史顺序一次性交接可转发事件,因此 inbound 顺序为: ```text initialize @@ -385,7 +373,6 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | event、queue 或 outcome 写入失败 | Send 事务整体回滚 | | queue event 不属于当前 Session | 创建失败;不写 inbound、不删 queue、不激活 | | 事件转换失败 | 创建失败;queue 保留 | -| 快照与锁定后的完整 queue 不一致 | 激活事务无写入,重新读取并重试 | | 任一 inbound 写入或 sequence 更新失败 | 激活事务整体回滚,queue 保留,状态仍为 `initializing` | | active 更新没有恰好影响一行 | 激活事务回滚 | | Code Session 创建流程失败 | 现有 cleanup terminate 未完成的 Code Session | @@ -400,9 +387,10 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | Send 事务和 startup/realtime 分流 | `DB.AppendSessionEventsForDelivery` | | 启动窗口判断 | `shouldQueueForStartup` | | queue 写入 | `enqueueSessionEventsTx` | -| queue 快照及 ownership 加载 | `DB.ListSessionEventQueueItems` | -| Code Session 创建和消费循环 | `Service.CreateManagedAgentCodeSession`、`activateManagedAgentCodeSession` | -| 完整 queue 原子交接与激活 | `Service.CommitManagedAgentCodeSessionActivation`、`DB.WithManagedAgentActivationTx` | +| queue 事务内加载及 ownership / type 校验(不决定 inbound 序) | `ManagedAgentActivationTx.ListSessionEventQueueItems` | +| 激活历史的稳定顺序读取 | `ManagedAgentActivationTx.ListSessionEventsForActivation` | +| 激活 inbound 分块批量写入 | `ManagedAgentActivationTx.AppendCodeSessionInboundEvents` | +| queue、history、inbound 与 active 原子交接 | `Service.CommitManagedAgentCodeSessionActivation`、`DB.WithManagedAgentActivationTx` | | Deployment initial events 入队 | `DB.CreateManualDeploymentRun` | | active 当前 batch 投递 | `Service.QueuePublicSessionEvents` | @@ -415,12 +403,14 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | 两条普通启动消息并发 | Session 行锁串行化;一个 200,一个 409 | | 启动期多事件 batch 包含 `user.message` | 整体 409,无部分副作用 | | 被拒绝 batch 包含 `user.define_outcome` | outcome 不变化 | -| queue 快照后新消息进入 | 第一次激活不写入并重试,最终完整交接 | +| 激活等待 Session 锁时新消息先提交 | 激活取得锁后读取最新 queue 与历史并完整交接 | +| 激活取得 Session 锁后收到 `user.interrupt` | Send 等待激活提交,随后看到 active 并走 realtime | +| 启动期依次收到 `user.message`、`user.interrupt` | inbound 保持 message → interrupt 顺序 | | queue 引用另一个 Session 的 event | 拒绝激活,queue 保留 | -| 第二条 inbound 写入失败 | 第一条 inbound 也回滚,queue 全部保留,Code Session 仍 initializing | +| 已写一条 inbound 后,后续事件转换失败 | 已写 inbound 回滚,queue 全部保留,Code Session 仍 initializing | | Deployment 包含多条 initial user messages | `initialize` 后按输入顺序写入全部消息 | | 激活事务先于 Send 获得 Session 锁 | 激活原子提交,后续消息走 realtime | -| Send 事务先于激活获得 Session 锁 | 消息进入 queue,激活发现变化后重试 | +| Send 事务先于激活获得 Session 锁 | 消息先提交,激活随后读取最新 queue 与历史 | | Code Session 已 active | 不写 startup queue,只实时投当前 batch | | Code Session 非 active | 不实时写 inbound | | Environment 类型为 self_hosted 等 | 与 cloud 相同:只要 CS 未 active 且存在指向该 Session 的在途 work,即可能进 startup queue;**不**因 environment type 跳过 queue | diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 300bbce5..0a6409e3 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -4,11 +4,9 @@ import ( "context" "encoding/json" "errors" - "fmt" "strings" "time" - "github.com/samber/lo" "github.com/superduck-ai/open-managed-agents/internal/auth" "github.com/superduck-ai/open-managed-agents/internal/db" "github.com/superduck-ai/open-managed-agents/internal/ids" @@ -100,7 +98,7 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag if err := s.queueInitialize(ctx, record, input.Config, now); err != nil { return ManagedAgentCreateResult{}, err } - if err := s.activateManagedAgentCodeSession(ctx, input.Session, record); err != nil { + if err := s.CommitManagedAgentCodeSessionActivation(ctx, record); err != nil { return ManagedAgentCreateResult{}, err } credentialContext, err := s.db.GetCodeSessionCredentialContextForIssue( @@ -127,81 +125,17 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag }, nil } -// activateManagedAgentCodeSession loads the startup queue and public session -// history outside the activation transaction, converts them to inbound inputs, -// then commits activation under the same Session row lock used by Send Events. -// -// Inbound order after initialize: forwardable session history excluding events -// still referenced by the startup queue, then queue items in FIFO order. The -// queue snapshot is the only cutover signal: if it changes before commit, the -// loop reloads and retries. -func (s *Service) activateManagedAgentCodeSession( - ctx context.Context, - session db.Session, - codeSession db.CodeSession, -) error { - for { - queueItems, err := s.db.ListSessionEventQueueItems(ctx, session) - if err != nil { - return err - } - sessionEvents, err := s.listAllSessionEvents(ctx, session) - if err != nil { - return err - } - queuedEventUUIDs := lo.SliceToMap(queueItems, func(item db.SessionEventQueueItem) (string, struct{}) { - return item.Event.UUID, struct{}{} - }) - sessionEventsOutsideQueue := lo.Filter(sessionEvents, func(event db.SessionEvent, _ int) bool { - _, queued := queuedEventUUIDs[event.UUID] - return !queued - }) - historyInbound, err := s.convertSessionEventsToInbound(codeSession.ExternalID, sessionEventsOutsideQueue) - if err != nil { - return err - } - queueInbound, err := lo.MapErr(queueItems, func(item db.SessionEventQueueItem, _ int) (db.AppendCodeSessionEventInput, error) { - if item.Event.EventType != "user.message" { - return db.AppendCodeSessionEventInput{}, fmt.Errorf( - "%w: session event queue contains a non-user message", - db.ErrInvalidState, - ) - } - return s.convertSessionEventToInbound(codeSession.ExternalID, item.Event) - }) - if err != nil { - return err - } - inboundInputs := append(historyInbound, queueInbound...) - committed, err := s.CommitManagedAgentCodeSessionActivation( - ctx, - codeSession, - queueItems, - inboundInputs, - ) - if err != nil { - return err - } - if committed { - return nil - } - } -} - -// CommitManagedAgentCodeSessionActivation writes inbound inputs, clears the -// matched startup queue snapshot, and marks the Code Session active in one -// transaction. It returns committed=false when the locked queue no longer -// matches queueItems so the caller can reload and retry without partial writes. +// CommitManagedAgentCodeSessionActivation locks the owning Session, loads the +// startup queue and complete public history, writes forwardable events in +// stable order, clears the queue, and activates the Code Session atomically. func (s *Service) CommitManagedAgentCodeSessionActivation( ctx context.Context, codeSession db.CodeSession, - queueItems []db.SessionEventQueueItem, - inboundInputs []db.AppendCodeSessionEventInput, -) (committed bool, err error) { +) error { if s == nil || s.db == nil { - return false, db.ErrNotFound + return db.ErrNotFound } - err = s.db.WithManagedAgentActivationTx(ctx, func(tx db.ManagedAgentActivationTx) error { + return s.db.WithManagedAgentActivationTx(ctx, func(tx db.ManagedAgentActivationTx) error { session, err := tx.LockSessionForEvents( ctx, codeSession.WorkspaceUUID, @@ -210,93 +144,52 @@ func (s *Service) CommitManagedAgentCodeSessionActivation( if err != nil { return err } - codeSession, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) + lockedCodeSession, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) if err != nil { return err } - if !lo.EveryBy(queueItems, func(item db.SessionEventQueueItem) bool { - return item.Event.EventType == "user.message" - }) { - return db.ErrInvalidState + // Queue rows are only a startup-delivery responsibility check. Inbound + // payloads and order come from locked public history below; after that + // succeeds the queue is cleared as the handoff completes. + queuedEvents, err := tx.ListSessionEventQueueItems(ctx, session) + if err != nil { + return err } - queueMatches, err := tx.QueueMatches(ctx, session, queueItems) - if err != nil || !queueMatches { + for _, event := range queuedEvents { + if event.EventType != "user.message" { + return db.ErrInvalidState + } + } + sessionEvents, err := tx.ListSessionEventsForActivation(ctx, session) + if err != nil { return err } - for _, inbound := range inboundInputs { - inserted, duplicate, err := tx.AppendCodeSessionInboundEvent(ctx, codeSession, inbound) + inboundInputs := make([]db.AppendCodeSessionEventInput, 0, len(sessionEvents)) + for _, event := range sessionEvents { + if !forwardPublicEventToWorker(event.EventType) { + continue + } + inbound, err := s.convertSessionEventToInbound(lockedCodeSession.ExternalID, event) if err != nil { return err } - if duplicate && inserted.CodeSessionExternalID != codeSession.ExternalID { - return db.ErrInvalidState - } - if !duplicate { - codeSession.LastInboundSequenceNum = inserted.SequenceNum - } + inboundInputs = append(inboundInputs, inbound) + } + if err := tx.AppendCodeSessionInboundEvents(ctx, lockedCodeSession, inboundInputs); err != nil { + return err } if err := tx.DeleteSessionEventQueue(ctx, session.UUID); err != nil { return err } - statusUpdated, err := tx.ActivateCodeSession(ctx, codeSession.UUID, time.Now().UTC()) + statusUpdated, err := tx.ActivateCodeSession(ctx, lockedCodeSession.UUID, time.Now().UTC()) if err != nil { return err } if !statusUpdated { return db.ErrInvalidState } - committed = true return nil }) - if err != nil { - return false, err - } - return committed, nil -} - -// listAllSessionEvents returns every non-deleted public session event in -// ascending creation order by paging through ListSessionEventsPage. -func (s *Service) listAllSessionEvents(ctx context.Context, session db.Session) ([]db.SessionEvent, error) { - var all []db.SessionEvent - var cursor *db.SessionEventPageCursor - for { - page, hasMore, err := s.db.ListSessionEventsPage(ctx, db.ListSessionEventsPageParams{ - WorkspaceUUID: session.WorkspaceUUID, - SessionExternalID: session.ExternalID, - Limit: 100, - Cursor: cursor, - Order: "asc", - }) - if err != nil { - return nil, err - } - all = append(all, page...) - if !hasMore || len(page) == 0 { - return all, nil - } - last := page[len(page)-1] - cursor = &db.SessionEventPageCursor{CreatedAt: last.CreatedAt, UUID: last.UUID} - } -} - -// convertSessionEventsToInbound keeps forwardable session events and converts -// each into an inbound write input for the given Code Session. -func (s *Service) convertSessionEventsToInbound( - codeSessionID string, - events []db.SessionEvent, -) ([]db.AppendCodeSessionEventInput, error) { - inboundInputs := make([]db.AppendCodeSessionEventInput, 0, len(events)) - for _, event := range events { - if !forwardPublicEventToWorker(event.EventType) { - continue - } - inbound, err := s.convertSessionEventToInbound(codeSessionID, event) - if err != nil { - return nil, err - } - inboundInputs = append(inboundInputs, inbound) - } - return inboundInputs, nil } // convertSessionEventToInbound maps one public session event payload into a diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index f96a87bb..08ab7281 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -294,12 +294,183 @@ func (tx ManagedAgentActivationTx) LockInitializingCodeSession( }) } -func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvent( +const managedAgentActivationInboundBatchSize = 500 + +func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvents( ctx context.Context, codeSession CodeSession, - input AppendCodeSessionEventInput, -) (CodeSessionEvent, bool, error) { - return tx.database.appendCodeSessionEventSQLXTx(ctx, tx.tx, codeSession, "inbound", input) + inputs []AppendCodeSessionEventInput, +) error { + if len(inputs) == 0 { + return nil + } + existing, err := listExistingActivationInboundEvents(ctx, tx.tx, codeSession, inputs) + if err != nil { + return err + } + rows, lastSequence, err := activationInboundEventInsertRows(codeSession, inputs, existing) + if err != nil { + return err + } + if len(rows) == 0 { + return nil + } + for start := 0; start < len(rows); start += managedAgentActivationInboundBatchSize { + end := min(start+managedAgentActivationInboundBatchSize, len(rows)) + result, err := tx.tx.NamedExecContext(ctx, ` + insert into code_session_inbound_events ( + external_id, organization_uuid, workspace_uuid, code_session_uuid, + code_session_external_id, sequence_num, event_type, event_subtype, + payload_uuid, request_id, payload, payload_hash, idempotency_key, + delivery_status, source, created_at, updated_at + ) + values ( + :external_id, :organization_uuid, :workspace_uuid, :code_session_uuid, + :code_session_external_id, :sequence_num, :event_type, :event_subtype, + :payload_uuid, :request_id, CAST(:payload AS jsonb), :payload_hash, + :idempotency_key, :delivery_status, :source, :created_at, :created_at + ) + `, rows[start:end]) + if err != nil { + return err + } + inserted, err := result.RowsAffected() + if err != nil { + return err + } + if inserted != int64(end-start) { + return ErrInvalidState + } + } + updated, err := namedExecRowsAffected(ctx, tx.tx, ` + update code_sessions + set last_inbound_sequence_num = :sequence_num, updated_at = :now + where uuid = :uuid + `, map[string]any{ + "sequence_num": lastSequence, + "now": time.Now().UTC(), + "uuid": dbUUID(codeSession.UUID), + }) + if err != nil { + return err + } + if updated != 1 { + return ErrInvalidState + } + return nil +} + +func listExistingActivationInboundEvents( + ctx context.Context, + database sqlxNamedQueryer, + codeSession CodeSession, + inputs []AppendCodeSessionEventInput, +) (map[string]struct{}, error) { + idempotencyKeys := make([]string, 0, len(inputs)) + seen := make(map[string]struct{}, len(inputs)) + for _, input := range inputs { + if input.IdempotencyKey == "" { + continue + } + if _, ok := seen[input.IdempotencyKey]; ok { + continue + } + seen[input.IdempotencyKey] = struct{}{} + idempotencyKeys = append(idempotencyKeys, input.IdempotencyKey) + } + if len(idempotencyKeys) == 0 { + return map[string]struct{}{}, nil + } + + var rows []codeSessionInboundEventIdentityRow + if err := namedSelectContext(ctx, database, &rows, ` + select code_session_external_id, idempotency_key + from code_session_inbound_events + where organization_uuid = :organization_uuid + and workspace_uuid = :workspace_uuid + and idempotency_key = any(:idempotency_keys) + and deleted_at is null + `, map[string]any{ + "organization_uuid": dbUUID(codeSession.OrganizationUUID), + "workspace_uuid": dbUUID(codeSession.WorkspaceUUID), + "idempotency_keys": idempotencyKeys, + }); err != nil { + return nil, err + } + existing := make(map[string]struct{}, len(rows)) + for _, row := range rows { + if row.CodeSessionExternalID != codeSession.ExternalID { + return nil, ErrInvalidState + } + existing[row.IdempotencyKey] = struct{}{} + } + return existing, nil +} + +func activationInboundEventInsertRows( + codeSession CodeSession, + inputs []AppendCodeSessionEventInput, + existing map[string]struct{}, +) ([]codeSessionInboundEventInsertRow, int64, error) { + organizationUUID, err := parseDBUUID("organization_uuid", codeSession.OrganizationUUID) + if err != nil { + return nil, 0, err + } + workspaceUUID, err := parseDBUUID("workspace_uuid", codeSession.WorkspaceUUID) + if err != nil { + return nil, 0, err + } + codeSessionUUID, err := parseDBUUID("code_session_uuid", codeSession.UUID) + if err != nil { + return nil, 0, err + } + + now := time.Now().UTC() + sequence := codeSession.LastInboundSequenceNum + rows := make([]codeSessionInboundEventInsertRow, 0, len(inputs)) + seen := make(map[string]struct{}, len(inputs)) + for _, input := range inputs { + if input.RequiredWorkerEpoch != nil && codeSession.CurrentWorkerEpoch != *input.RequiredWorkerEpoch { + return nil, 0, ErrWorkerEpochMismatch + } + if input.IdempotencyKey != "" { + if _, ok := existing[input.IdempotencyKey]; ok { + continue + } + if _, ok := seen[input.IdempotencyKey]; ok { + continue + } + seen[input.IdempotencyKey] = struct{}{} + } + createdAt := input.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + deliveryStatus := input.DeliveryStatus + if deliveryStatus == "" { + deliveryStatus = "queued" + } + sequence++ + rows = append(rows, codeSessionInboundEventInsertRow{ + ExternalID: input.ExternalID, + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + CodeSessionUUID: codeSessionUUID, + CodeSessionExternalID: codeSession.ExternalID, + SequenceNum: sequence, + EventType: input.EventType, + EventSubtype: input.EventSubtype, + PayloadUUID: input.PayloadUUID, + RequestID: input.RequestID, + Payload: []byte(input.Payload), + PayloadHash: input.PayloadHash, + IdempotencyKey: input.IdempotencyKey, + DeliveryStatus: deliveryStatus, + Source: input.Source, + CreatedAt: createdAt, + }) + } + return rows, sequence, nil } func (tx ManagedAgentActivationTx) ActivateCodeSession( diff --git a/internal/db/code_sessions_sqlx.go b/internal/db/code_sessions_sqlx.go index 97dd5766..e0125683 100644 --- a/internal/db/code_sessions_sqlx.go +++ b/internal/db/code_sessions_sqlx.go @@ -75,6 +75,30 @@ type codeSessionEventRow struct { DeletedAt *time.Time `db:"deleted_at"` } +type codeSessionInboundEventIdentityRow struct { + CodeSessionExternalID string `db:"code_session_external_id"` + IdempotencyKey string `db:"idempotency_key"` +} + +type codeSessionInboundEventInsertRow struct { + ExternalID string `db:"external_id"` + OrganizationUUID uuid.UUID `db:"organization_uuid"` + WorkspaceUUID uuid.UUID `db:"workspace_uuid"` + CodeSessionUUID uuid.UUID `db:"code_session_uuid"` + CodeSessionExternalID string `db:"code_session_external_id"` + SequenceNum int64 `db:"sequence_num"` + EventType string `db:"event_type"` + EventSubtype string `db:"event_subtype"` + PayloadUUID *string `db:"payload_uuid"` + RequestID *string `db:"request_id"` + Payload []byte `db:"payload"` + PayloadHash string `db:"payload_hash"` + IdempotencyKey string `db:"idempotency_key"` + DeliveryStatus string `db:"delivery_status"` + Source string `db:"source"` + CreatedAt time.Time `db:"created_at"` +} + type codeSessionInternalEventRow struct { UUID uuid.UUID `db:"uuid"` ExternalID string `db:"external_id"` diff --git a/internal/db/managed_agent_activation.go b/internal/db/managed_agent_activation.go index dd818291..d1c2f9ba 100644 --- a/internal/db/managed_agent_activation.go +++ b/internal/db/managed_agent_activation.go @@ -9,8 +9,7 @@ import ( // ManagedAgentActivationTx exposes the resource-scoped SQL operations used by // the code-session service to atomically hand off startup events. type ManagedAgentActivationTx struct { - database *DB - tx *sqlx.Tx + tx *sqlx.Tx } // WithManagedAgentActivationTx owns the database transaction lifecycle while @@ -25,7 +24,7 @@ func (d *DB) WithManagedAgentActivationTx( } defer func() { _ = tx.Rollback() }() - if err := fn(ManagedAgentActivationTx{database: d, tx: tx}); err != nil { + if err := fn(ManagedAgentActivationTx{tx: tx}); err != nil { return err } return tx.Commit() diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index ec9fd32e..bcb60c86 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -19,18 +19,8 @@ const ( SessionEventDeliveryStartupQueued SessionEventDelivery = "startup_queued" ) -// SessionEventQueueItem couples one temporary queue identity with its owned -// public Session event. -type SessionEventQueueItem struct { - id int64 - sessionUUID uuid.UUID - sessionEventUUID uuid.UUID - Event SessionEvent -} - type sessionEventQueueIdentityRow struct { ID int64 `db:"id"` - SessionUUID uuid.UUID `db:"session_uuid"` SessionEventUUID uuid.UUID `db:"session_event_uuid"` } @@ -119,13 +109,24 @@ func (d *DB) AppendSessionEventsForDelivery( return created, delivery, nil } -// ListSessionEventQueueItems returns the current startup queue in FIFO order -// and rejects references that do not belong to the supplied Session. -func (d *DB) ListSessionEventQueueItems( +// ListSessionEventQueueItems returns and locks the public events currently +// referenced by the startup queue for the Session already locked by the +// activation transaction. Callers use this only to validate ownership and +// event type before clearing the queue; inbound content and order come from +// ListSessionEventsForActivation. +func (tx ManagedAgentActivationTx) ListSessionEventQueueItems( ctx context.Context, session Session, -) ([]SessionEventQueueItem, error) { - identityRows, err := listSessionEventQueueIdentityRows(ctx, d.sql, session, false) +) ([]SessionEvent, error) { + return listSessionEventQueueItems(ctx, tx.tx, session) +} + +func listSessionEventQueueItems( + ctx context.Context, + database sqlxNamedQueryer, + session Session, +) ([]SessionEvent, error) { + identityRows, err := listSessionEventQueueIdentityRows(ctx, database, session) if err != nil { return nil, err } @@ -133,12 +134,12 @@ func (d *DB) ListSessionEventQueueItems( return nil, nil } - eventsByUUID, err := sessionEventsByUUIDs(ctx, d.sql, session, identityRows) + eventsByUUID, err := sessionEventsByUUIDs(ctx, database, session, identityRows) if err != nil { return nil, err } - queueItems := make([]SessionEventQueueItem, 0, len(identityRows)) + events := make([]SessionEvent, 0, len(identityRows)) for _, row := range identityRows { event, ok := eventsByUUID[row.SessionEventUUID.String()] if !ok { @@ -149,14 +150,31 @@ func (d *DB) ListSessionEventQueueItems( session.ExternalID, ) } - queueItems = append(queueItems, SessionEventQueueItem{ - id: row.ID, - sessionUUID: row.SessionUUID, - sessionEventUUID: row.SessionEventUUID, - Event: event, - }) + events = append(events, event) } - return queueItems, nil + return events, nil +} + +// ListSessionEventsForActivation returns complete public history in stable +// creation order. The database identity is used only to preserve insertion +// order when a batch shares one created_at timestamp. +func (tx ManagedAgentActivationTx) ListSessionEventsForActivation( + ctx context.Context, + session Session, +) ([]SessionEvent, error) { + return listSessionEventsSQLX(ctx, tx.tx, ` + select `+sessionEventSQLXColumns+` + from session_events + where organization_uuid = :organization_uuid + and workspace_uuid = :workspace_uuid + and session_uuid = :session_uuid + and deleted_at is null + order by created_at asc, id asc + `, map[string]any{ + "organization_uuid": dbUUID(session.OrganizationUUID), + "workspace_uuid": dbUUID(session.WorkspaceUUID), + "session_uuid": dbUUID(session.UUID), + }) } func sessionEventsByUUIDs( @@ -193,19 +211,16 @@ func listSessionEventQueueIdentityRows( ctx context.Context, database sqlxNamedQueryer, session Session, - lock bool, ) ([]sessionEventQueueIdentityRow, error) { // session_uuid uniquely identifies the public Session; tenant columns are // written on insert but are not required as query predicates. query := ` - select q.id, q.session_uuid, q.session_event_uuid + select q.id, q.session_event_uuid from session_event_queue q where q.session_uuid = :session_uuid order by q.id asc + for update of q ` - if lock { - query += ` for update of q` - } var rows []sessionEventQueueIdentityRow err := namedSelectContext(ctx, database, &rows, query, map[string]any{ "session_uuid": dbUUID(session.UUID), @@ -216,37 +231,6 @@ func listSessionEventQueueIdentityRows( return rows, nil } -func queueItemsMatch( - rows []sessionEventQueueIdentityRow, - queueItems []SessionEventQueueItem, -) bool { - if len(rows) != len(queueItems) { - return false - } - for i := range rows { - if rows[i].ID != queueItems[i].id || - rows[i].SessionUUID != queueItems[i].sessionUUID || - rows[i].SessionEventUUID != queueItems[i].sessionEventUUID { - return false - } - } - return true -} - -// QueueMatches reports whether the locked startup queue still matches the -// caller's queueItems snapshot (count, order, and identity fields). -func (tx ManagedAgentActivationTx) QueueMatches( - ctx context.Context, - session Session, - queueItems []SessionEventQueueItem, -) (bool, error) { - rows, err := listSessionEventQueueIdentityRows(ctx, tx.tx, session, true) - if err != nil { - return false, err - } - return queueItemsMatch(rows, queueItems), nil -} - func (tx ManagedAgentActivationTx) DeleteSessionEventQueue( ctx context.Context, sessionUUID string, diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index 835c00ae..bf8d0add 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -343,7 +343,7 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te const prompt = "startup-window message must reach inbound" ids := getDefaultDBIDs(t, app.db) - var acceptedEventID string + var acceptedEventID, interruptEventID string provider := &recordingRunnerProvider{ sandboxID: "sandbox-runner-startup-message", beforeCreate: func() { @@ -355,6 +355,11 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te t.Fatalf("accepted events = %#v, want one", sent.Data) } acceptedEventID = sessionEventStringField(t, sent.Data[0], "id") + interrupt := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.interrupt"}]}`, defaultTestKey) + if len(interrupt.Data) != 1 { + t.Fatalf("accepted interrupt = %#v, want one", interrupt.Data) + } + interruptEventID = sessionEventStringField(t, interrupt.Data[0], "id") }, } @@ -375,12 +380,13 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te if err != nil { t.Fatalf("list queued inbound events: %v", err) } - if len(queued) != 2 || + if len(queued) != 3 || queued[0].EventSubtype != "initialize" || queued[1].EventType != "user" || !bytes.Contains(queued[1].Payload, []byte(acceptedEventID)) || - !bytes.Contains(queued[1].Payload, []byte(prompt)) { - t.Fatalf("inbound = %#v, want initialize then accepted user message", queued) + !bytes.Contains(queued[1].Payload, []byte(prompt)) || + !bytes.Contains(queued[2].Payload, []byte(interruptEventID)) { + t.Fatalf("inbound = %#v, want initialize, accepted user message, interrupt", queued) } if remaining := sessionEventQueueEventIDs(t, app, session.ID); len(remaining) != 0 { t.Fatalf("remaining startup session event queue = %#v, want empty", remaining) diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 0c256c85..f639fab5 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -706,18 +706,6 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) t.Fatalf("set code session initializing: %v", err) } - ids := getDefaultDBIDs(t, app.db) - sessionRecord, err := app.db.GetSession(ctx, ids.WorkspaceUUID, session.ID) - if err != nil { - t.Fatalf("load Session for activation: %v", err) - } - staleItems, err := app.db.ListSessionEventQueueItems(ctx, sessionRecord) - if err != nil { - t.Fatalf("list empty Session event queue: %v", err) - } - if len(staleItems) != 0 { - t.Fatalf("initial Session event queue = %#v, want empty", staleItems) - } codeSession, err := app.db.GetCodeSession(ctx, codeSessionID) if err != nil { t.Fatalf("load initializing code session: %v", err) @@ -730,43 +718,9 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) } acceptedEventID := sessionEventStringField(t, accepted.Data[0], "id") - committed, err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession, staleItems, nil) - if err != nil { - t.Fatalf("reject stale activation queue snapshot: %v", err) - } - if committed { - t.Fatal("activation succeeded with a stale empty queue snapshot") - } - queueItems, err := app.db.ListSessionEventQueueItems(ctx, sessionRecord) - if err != nil { - t.Fatalf("list Session event queue for activation: %v", err) - } - if len(queueItems) != 1 { - t.Fatalf("Session event queue items = %#v, want one", queueItems) - } - - committed, err = codeSessionService.CommitManagedAgentCodeSessionActivation( - ctx, - codeSession, - queueItems, - []db.AppendCodeSessionEventInput{{ - ExternalID: "csev_activation_" + strings.TrimPrefix(codeSessionID, "cse_"), - EventType: "user", - EventSubtype: "message", - Payload: json.RawMessage(`{"type":"user","uuid":` + quoteJSON(acceptedEventID) + `,"message":{"role":"user","content":"must be delivered before activation"}}`), - PayloadHash: "activation-queue-hash", - IdempotencyKey: "activation-queue:" + acceptedEventID, - DeliveryStatus: "queued", - Source: "public-session", - CreatedAt: time.Now().UTC(), - }}, - ) - if err != nil { + if err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession); err != nil { t.Fatalf("activate with queued session event: %v", err) } - if !committed { - t.Fatal("activation did not commit the queued session event") - } codeSession, err = app.db.GetCodeSession(ctx, codeSessionID) if err != nil { t.Fatalf("reload activated code session: %v", err) @@ -807,7 +761,72 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) } } -func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { +func TestManagedAgentActivationPreservesLargeHistoryOrder(t *testing.T) { + ctx := context.Background() + app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-large-history-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-managed-agent-large-history-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-managed-agent-large-history-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + sessionResponse := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, sessionResponse.ID) + codeSessionID := launchLocalCodeSession(t, app, sessionResponse.ID) + if _, err := app.db.Pool.Exec(ctx, `update code_sessions set status = 'initializing' where external_id = $1`, codeSessionID); err != nil { + t.Fatalf("set Code Session initializing: %v", err) + } + + session, err := app.db.GetSession(ctx, getDefaultDBIDs(t, app.db).WorkspaceUUID, sessionResponse.ID) + if err != nil { + t.Fatalf("load Session: %v", err) + } + codeSession, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("load Code Session: %v", err) + } + const eventCount = 501 + createdAt := time.Now().UTC() + uuidPrefix := uuid.New() + eventIDs := make([]string, 0, eventCount) + events := make([]db.SessionEvent, 0, eventCount) + for i := range eventCount { + eventID := fmt.Sprintf("sevt_large_history_%03d_%s", i, strings.TrimPrefix(codeSessionID, "cse_")) + eventUUID := uuidPrefix + eventUUID[14] = byte((eventCount - i) >> 8) + eventUUID[15] = byte(eventCount - i) + eventIDs = append(eventIDs, eventID) + events = append(events, db.SessionEvent{ + UUID: eventUUID.String(), + ExternalID: eventID, + EventType: "user.interrupt", + Payload: json.RawMessage(`{"type":"user.interrupt","id":` + quoteJSON(eventID) + `}`), + ProcessedAt: createdAt, + CreatedAt: createdAt, + }) + } + if _, err := app.db.AppendSessionEvents(ctx, session.WorkspaceUUID, session.ExternalID, events); err != nil { + t.Fatalf("append large history: %v", err) + } + if err := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil).CommitManagedAgentCodeSessionActivation(ctx, codeSession); err != nil { + t.Fatalf("activate Code Session: %v", err) + } + + inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) + if err != nil { + t.Fatalf("list inbound: %v", err) + } + if len(inbound) != eventCount+1 { + t.Fatalf("inbound count = %d, want %d", len(inbound), eventCount+1) + } + for i, eventID := range eventIDs { + if !bytes.Contains(inbound[i+1].Payload, []byte(eventID)) { + t.Fatalf("inbound %d is out of order: %s", i, inbound[i+1].Payload) + } + } +} + +func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing.T) { ctx := context.Background() app := newTestAppWithStore(t, nil, newFakeStore("sessions-event-queue-rollback-bucket")) defer app.close() @@ -836,31 +855,24 @@ func TestSessionEventQueueDeliveryRollsBackOnInboundFailure(t *testing.T) { if err != nil { t.Fatalf("load rollback Code Session: %v", err) } - codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) - queueItems, err := app.db.ListSessionEventQueueItems(ctx, session) - if err != nil { - t.Fatalf("load rollback queue items: %v", err) - } - if len(queueItems) != 1 { - t.Fatalf("rollback queue items = %#v, want one", queueItems) + invalidAt := time.Now().UTC().Add(time.Second) + if _, err := app.db.AppendSessionEvents(ctx, session.WorkspaceUUID, session.ExternalID, []db.SessionEvent{{ + UUID: uuid.NewString(), + ExternalID: "sevt_activation_invalid_" + strings.TrimPrefix(codeSessionID, "cse_"), + EventType: "user.interrupt", + Payload: json.RawMessage(`[]`), + ProcessedAt: invalidAt, + CreatedAt: invalidAt, + }}); err != nil { + t.Fatalf("append invalid forwardable history event: %v", err) } + codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) before, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) if err != nil || len(before) != 1 { t.Fatalf("rollback inbound before delivery = (%#v, %v), want initialize", before, err) } - committed, err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession, queueItems, []db.AppendCodeSessionEventInput{{ - ExternalID: before[0].ExternalID, - EventType: "user", - EventSubtype: "message", - Payload: json.RawMessage(`{"type":"user","uuid":"rollback-user"}`), - PayloadHash: "rollback-hash", - IdempotencyKey: "rollback-idempotency", - DeliveryStatus: "queued", - Source: "public-session", - CreatedAt: time.Now().UTC(), - }}) - if err == nil || committed { - t.Fatal("queue delivery with duplicate inbound external ID succeeded") + if err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession); err == nil { + t.Fatal("activation with invalid forwardable history succeeded") } if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 1 { t.Fatalf("queue after rolled-back inbound = %#v, want one row", queued) From c8c0ed0f01072d219126724e2f0a838673585979 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Mon, 3 Aug 2026 15:53:25 +0800 Subject: [PATCH 18/23] Migrate session activation SQL to generated yourbatis mappers --- .gitignore | 1 + .../be/session-startup-message-delivery.md | 17 +- go.mod | 13 +- go.sum | 36 +- .../managed_agent_code_session.go | 12 +- internal/codesessions/service.go | 12 +- .../db/code_session_inbound_event_mapper.go | 25 + .../db/code_session_inbound_event_mapper.xml | 65 +++ internal/db/code_session_mapper.go | 36 ++ internal/db/code_session_mapper.xml | 75 +++ internal/db/code_sessions.go | 134 +++-- internal/db/db.go | 12 +- internal/db/deployments.go | 4 +- internal/db/environment_work_mapper.go | 19 + internal/db/environment_work_mapper.xml | 19 + internal/db/managed_agent_activation.go | 27 +- internal/db/session_event_mapper.go | 30 ++ internal/db/session_event_mapper.xml | 80 +++ internal/db/session_event_queue.go | 474 +++++++++++------- internal/db/session_event_queue_mapper.go | 33 ++ internal/db/session_event_queue_mapper.xml | 47 ++ internal/db/session_event_queue_sqlx.go | 104 ++++ internal/db/session_mapper.go | 25 + internal/db/session_mapper.xml | 48 ++ internal/db/session_thread_mapper.go | 25 + internal/db/session_thread_mapper.xml | 47 ++ internal/db/sessions.go | 3 +- internal/db/sessions_migration_sqlx_test.go | 12 +- internal/db/sessions_sqlx.go | 22 +- internal/db/yourbatis_mappers_test.go | 237 +++++++++ 30 files changed, 1365 insertions(+), 329 deletions(-) create mode 100644 internal/db/code_session_inbound_event_mapper.go create mode 100644 internal/db/code_session_inbound_event_mapper.xml create mode 100644 internal/db/code_session_mapper.go create mode 100644 internal/db/code_session_mapper.xml create mode 100644 internal/db/environment_work_mapper.go create mode 100644 internal/db/environment_work_mapper.xml create mode 100644 internal/db/session_event_mapper.go create mode 100644 internal/db/session_event_mapper.xml create mode 100644 internal/db/session_event_queue_mapper.go create mode 100644 internal/db/session_event_queue_mapper.xml create mode 100644 internal/db/session_event_queue_sqlx.go create mode 100644 internal/db/session_mapper.go create mode 100644 internal/db/session_mapper.xml create mode 100644 internal/db/session_thread_mapper.go create mode 100644 internal/db/session_thread_mapper.xml create mode 100644 internal/db/yourbatis_mappers_test.go diff --git a/.gitignore b/.gitignore index d1ecc2ca..43c8de9b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ logs/ CLAUDE.local.md AGENTS.override.md .worktrees/ +*.gen.go \ No newline at end of file diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index a72f5d51..53506ed7 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -197,6 +197,18 @@ normalization 阶段写库。 8. 有 outcome 变化时在同一事务更新; 9. commit 后返回 `startup_queued` 或 `realtime`。 +Send 和最终激活路径的锁查询、窗口判断、queue 读写、历史读取、inbound 批量写入和状态更新, +按主表分别声明在 `SessionMapper`、`SessionThreadMapper`、`SessionEventMapper`、 +`SessionEventQueueMapper`、`CodeSessionMapper`、`CodeSessionInboundEventMapper` 和 +`EnvironmentWorkMapper` 的 MyBatis XML 中,并由 yourbatis 生成静态 Go builder 与 scanner。 +单个 Mapper 不混合多个表的查询;跨表事务由 DB 组装层使用同一个事务 `Executor` 构造所需 Mapper。 +DB 组装层从 sqlx 使用的同一个 `*sql.DB` 创建共享 `yourbatis.DB`,不会建立第二个连接池。 +Send 和最终激活通过 `yourbatis.DB.Transaction` 开启事务,并用回调提供的事务 `Executor` +构造 Mapper;不再把 `sqlx.Tx` 包装成自定义 yourbatis Executor。Deployment 创建和 Session 删除 +仍包含尚未迁移的 sqlx 事务链,其 queue 操作继续使用同一个 sqlx transaction,避免一次业务事务 +跨两个句柄。仓库提交生成文件;yourbatis 版本由 `go.mod` 固定,需要重新生成时运行 +`go generate ./internal/db`。 + ```mermaid sequenceDiagram autonumber @@ -385,8 +397,9 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | Runner prepare 不再读取事件快照 | `Runner.prepareManagedAgentLaunch` | | API 标准化事件与 outcome | `Handler.sendEventsRoute`、`normalizeInputEvent` | | Send 事务和 startup/realtime 分流 | `DB.AppendSessionEventsForDelivery` | -| 启动窗口判断 | `shouldQueueForStartup` | -| queue 写入 | `enqueueSessionEventsTx` | +| 启动交接 SQL 声明与生成实现 | 按主表拆分的 `*Mapper`、对应 `*_mapper.xml` | +| 启动窗口判断 | `shouldQueueForStartup`、`shouldQueueForStartupSQLX` | +| queue 写入 | `enqueueSessionEventsTx`、`enqueueSessionEventsSQLXTx` | | queue 事务内加载及 ownership / type 校验(不决定 inbound 序) | `ManagedAgentActivationTx.ListSessionEventQueueItems` | | 激活历史的稳定顺序读取 | `ManagedAgentActivationTx.ListSessionEventsForActivation` | | 激活 inbound 分块批量写入 | `ManagedAgentActivationTx.AppendCodeSessionInboundEvents` | diff --git a/go.mod b/go.mod index 3db7bd83..0de11406 100644 --- a/go.mod +++ b/go.mod @@ -21,11 +21,12 @@ require ( github.com/samber/lo v1.53.0 github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 github.com/superduck-ai/e2b-go-sdk v0.0.1 + github.com/superduck-ai/yourbatis v0.1.0 go.opentelemetry.io/proto/otlp v1.10.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/net v0.53.0 - golang.org/x/sync v0.20.0 - golang.org/x/text v0.37.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + golang.org/x/text v0.38.0 google.golang.org/protobuf v1.36.11 ) @@ -63,9 +64,13 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/grpc v1.80.0 // indirect ) + +tool github.com/superduck-ai/yourbatis/cmd/sqlmapgen diff --git a/go.sum b/go.sum index a5db2b26..d50aea37 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -141,6 +141,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/superduck-ai/e2b-go-sdk v0.0.1 h1:yW23X/fEQvaPdJHhDnp8PgnsaIixn+UTVMt94X4Kr0s= github.com/superduck-ai/e2b-go-sdk v0.0.1/go.mod h1:dWlhv18vJamYp5gQ3ktmjjrX7tUaMe2OyTJXUo8EeXY= +github.com/superduck-ai/yourbatis v0.1.0 h1:U49je+0PhoR6PVJKfR3mbNo31rr+2SzkWwBi28wndwE= +github.com/superduck-ai/yourbatis v0.1.0/go.mod h1:BlCyyT1yfU2Zxya89rDf89keXqsdcwP6Q3PFscIjvig= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -177,18 +179,20 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= @@ -207,11 +211,11 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 0a6409e3..b698b5f9 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -136,7 +136,8 @@ func (s *Service) CommitManagedAgentCodeSessionActivation( return db.ErrNotFound } return s.db.WithManagedAgentActivationTx(ctx, func(tx db.ManagedAgentActivationTx) error { - session, err := tx.LockSessionForEvents( + // lock session by session external id + lockedSession, err := tx.LockSessionForEvents( ctx, codeSession.WorkspaceUUID, codeSession.SessionExternalID, @@ -144,6 +145,7 @@ func (s *Service) CommitManagedAgentCodeSessionActivation( if err != nil { return err } + // lock code_session by code session id lockedCodeSession, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) if err != nil { return err @@ -151,7 +153,7 @@ func (s *Service) CommitManagedAgentCodeSessionActivation( // Queue rows are only a startup-delivery responsibility check. Inbound // payloads and order come from locked public history below; after that // succeeds the queue is cleared as the handoff completes. - queuedEvents, err := tx.ListSessionEventQueueItems(ctx, session) + queuedEvents, err := tx.ListSessionEventQueueItems(ctx, lockedSession) if err != nil { return err } @@ -160,13 +162,13 @@ func (s *Service) CommitManagedAgentCodeSessionActivation( return db.ErrInvalidState } } - sessionEvents, err := tx.ListSessionEventsForActivation(ctx, session) + sessionEvents, err := tx.ListSessionEventsForActivation(ctx, lockedSession) if err != nil { return err } inboundInputs := make([]db.AppendCodeSessionEventInput, 0, len(sessionEvents)) for _, event := range sessionEvents { - if !forwardPublicEventToWorker(event.EventType) { + if !shouldForwardPublicEventToWorker(event.EventType) { continue } inbound, err := s.convertSessionEventToInbound(lockedCodeSession.ExternalID, event) @@ -178,7 +180,7 @@ func (s *Service) CommitManagedAgentCodeSessionActivation( if err := tx.AppendCodeSessionInboundEvents(ctx, lockedCodeSession, inboundInputs); err != nil { return err } - if err := tx.DeleteSessionEventQueue(ctx, session.UUID); err != nil { + if err := tx.DeleteSessionEventQueue(ctx, lockedSession.UUID); err != nil { return err } statusUpdated, err := tx.ActivateCodeSession(ctx, lockedCodeSession.UUID, time.Now().UTC()) diff --git a/internal/codesessions/service.go b/internal/codesessions/service.go index 49313e68..0e4084be 100644 --- a/internal/codesessions/service.go +++ b/internal/codesessions/service.go @@ -59,7 +59,7 @@ func (s *Service) QueuePublicSessionEvents(ctx context.Context, session db.Sessi } payloads := make([]json.RawMessage, 0, len(events)) for _, event := range events { - if !forwardPublicEventToWorker(event.EventType) { + if !shouldForwardPublicEventToWorker(event.EventType) { continue } if event.EventType == "user.tool_confirmation" { @@ -195,7 +195,7 @@ func (s *Service) AppendWorkerOutputEventsForEpoch(ctx context.Context, codeSess return err } } - if event.Ephemeral || !publicWorkerOutputEvent(event.EventType) { + if event.Ephemeral || !isPublicWorkerOutputEvent(event.EventType) { continue } publicPayloads, ok, err := publicPayloadsFromWorkerEvent(codeSessionID, event, publicObject) @@ -261,7 +261,7 @@ func (s *Service) appendWorkerEvent(ctx context.Context, codeSessionID string, r if meta.EventType == "control_request" && meta.EventSubtype == "can_use_tool" { return s.handleToolPermissionRequest(ctx, codeSessionID, object, meta) } - if hiddenWorkerEvent(meta.EventType) { + if isHiddenWorkerEvent(meta.EventType) { return nil } publicPayloads, ok, err := publicPayloadsFromWorkerEvent(codeSessionID, event, object) @@ -447,7 +447,7 @@ func (s *Service) subagentThreadMappings(ctx context.Context, codeSession db.Cod return threadByAgent, nil } -func forwardPublicEventToWorker(eventType string) bool { +func shouldForwardPublicEventToWorker(eventType string) bool { switch eventType { case "user.message", "user.interrupt", "user.tool_confirmation", "user.tool_result", "user.custom_tool_result": return true @@ -456,7 +456,7 @@ func forwardPublicEventToWorker(eventType string) bool { } } -func hiddenWorkerEvent(eventType string) bool { +func isHiddenWorkerEvent(eventType string) bool { switch eventType { case "control_request", "control_response", "control_cancel_request": return true @@ -465,7 +465,7 @@ func hiddenWorkerEvent(eventType string) bool { } } -func publicWorkerOutputEvent(eventType string) bool { +func isPublicWorkerOutputEvent(eventType string) bool { return maevents.IsWorkerOutputEvent(eventType) || maevents.IsStreamDelta(eventType) } diff --git a/internal/db/code_session_inbound_event_mapper.go b/internal/db/code_session_inbound_event_mapper.go new file mode 100644 index 00000000..734d69d8 --- /dev/null +++ b/internal/db/code_session_inbound_event_mapper.go @@ -0,0 +1,25 @@ +package db + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper CodeSessionInboundEventMapper -sql ./code_session_inbound_event_mapper.xml -dialect postgres + +// CodeSessionInboundEventMapper contains queries whose primary table is +// code_session_inbound_events. +type CodeSessionInboundEventMapper interface { + ListExistingActivationInboundEvents( + ctx context.Context, + organizationUUID uuid.UUID, + workspaceUUID uuid.UUID, + idempotencyKeys []string, + ) ([]codeSessionInboundEventIdentityRow, error) + + InsertCodeSessionInboundEvents( + ctx context.Context, + rows []codeSessionInboundEventInsertRow, + ) (int64, error) +} diff --git a/internal/db/code_session_inbound_event_mapper.xml b/internal/db/code_session_inbound_event_mapper.xml new file mode 100644 index 00000000..e0aa62ff --- /dev/null +++ b/internal/db/code_session_inbound_event_mapper.xml @@ -0,0 +1,65 @@ + + + + + + + + INSERT INTO code_session_inbound_events ( + external_id, + organization_uuid, + workspace_uuid, + code_session_uuid, + code_session_external_id, + sequence_num, + event_type, + event_subtype, + payload_uuid, + request_id, + payload, + payload_hash, + idempotency_key, + delivery_status, + source, + created_at, + updated_at + ) VALUES + + ( + #{row.ExternalID}, + #{row.OrganizationUUID}, + #{row.WorkspaceUUID}, + #{row.CodeSessionUUID}, + #{row.CodeSessionExternalID}, + #{row.SequenceNum}, + #{row.EventType}, + #{row.EventSubtype}, + #{row.PayloadUUID}, + #{row.RequestID}, + CAST(#{row.Payload} AS jsonb), + #{row.PayloadHash}, + #{row.IdempotencyKey}, + #{row.DeliveryStatus}, + #{row.Source}, + #{row.CreatedAt}, + #{row.CreatedAt} + ) + + + diff --git a/internal/db/code_session_mapper.go b/internal/db/code_session_mapper.go new file mode 100644 index 00000000..9b934856 --- /dev/null +++ b/internal/db/code_session_mapper.go @@ -0,0 +1,36 @@ +package db + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper CodeSessionMapper -sql ./code_session_mapper.xml -dialect postgres + +// CodeSessionMapper contains queries whose primary table is code_sessions. +type CodeSessionMapper interface { + LockInitializingCodeSession( + ctx context.Context, + codeSessionUUID uuid.UUID, + ) (codeSessionRow, bool, error) + + UpdateCodeSessionInboundSequence( + ctx context.Context, + codeSessionUUID uuid.UUID, + sequenceNum int64, + now time.Time, + ) (int64, error) + + ActivateCodeSession( + ctx context.Context, + codeSessionUUID uuid.UUID, + now time.Time, + ) (int64, error) + + GetLatestCodeSessionStatus( + ctx context.Context, + sessionUUID uuid.UUID, + ) (string, bool, error) +} diff --git a/internal/db/code_session_mapper.xml b/internal/db/code_session_mapper.xml new file mode 100644 index 00000000..d2d73672 --- /dev/null +++ b/internal/db/code_session_mapper.xml @@ -0,0 +1,75 @@ + + + + + + uuid, + external_id, + organization_uuid, + workspace_uuid, + session_uuid, + session_external_id, + environment_uuid, + environment_external_id, + work_dir, + permission_mode, + model, + status, + metadata, + connection_status, + last_inbound_sequence_num, + last_outbound_sequence_num, + last_internal_sequence_num, + last_worker_connected_at, + last_worker_activity_at, + current_worker_epoch, + worker_lease_expires_at, + worker_registered_at, + worker_last_heartbeat_at, + worker_token_session_id, + worker_binding, + worker_status, + worker_external_metadata, + worker_requires_action_details, + created_at, + updated_at, + deleted_at + + + + + + UPDATE code_sessions + SET last_inbound_sequence_num = #{sequenceNum}, + updated_at = #{now} + WHERE uuid = #{codeSessionUUID} + + + + UPDATE code_sessions + SET status = 'active', + updated_at = #{now} + WHERE uuid = #{codeSessionUUID} + AND status = 'initializing' + AND deleted_at IS NULL + + + + diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 08ab7281..4e401e17 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -10,6 +10,7 @@ import ( "time" "github.com/jmoiron/sqlx" + "github.com/samber/lo" ) type CodeSession struct { @@ -282,16 +283,18 @@ func (tx ManagedAgentActivationTx) LockInitializingCodeSession( ctx context.Context, codeSessionUUID string, ) (CodeSession, error) { - return getCodeSessionSQLX(ctx, tx.tx, ` - select `+codeSessionColumns()+` - from code_sessions - where uuid = :uuid - and status = 'initializing' - and deleted_at is null - for update - `, map[string]any{ - "uuid": dbUUID(codeSessionUUID), - }) + parsedUUID, err := parseDBUUID("code_session_uuid", codeSessionUUID) + if err != nil { + return CodeSession{}, err + } + row, found, err := tx.codeSessionMapper.LockInitializingCodeSession(ctx, parsedUUID) + if err != nil { + return CodeSession{}, err + } + if !found { + return CodeSession{}, ErrNotFound + } + return row.session(), nil } const managedAgentActivationInboundBatchSize = 500 @@ -304,7 +307,12 @@ func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvents( if len(inputs) == 0 { return nil } - existing, err := listExistingActivationInboundEvents(ctx, tx.tx, codeSession, inputs) + existing, err := listExistingActivationInboundEvents( + ctx, + tx.codeSessionInboundEventMapper, + codeSession, + inputs, + ) if err != nil { return err } @@ -317,24 +325,10 @@ func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvents( } for start := 0; start < len(rows); start += managedAgentActivationInboundBatchSize { end := min(start+managedAgentActivationInboundBatchSize, len(rows)) - result, err := tx.tx.NamedExecContext(ctx, ` - insert into code_session_inbound_events ( - external_id, organization_uuid, workspace_uuid, code_session_uuid, - code_session_external_id, sequence_num, event_type, event_subtype, - payload_uuid, request_id, payload, payload_hash, idempotency_key, - delivery_status, source, created_at, updated_at - ) - values ( - :external_id, :organization_uuid, :workspace_uuid, :code_session_uuid, - :code_session_external_id, :sequence_num, :event_type, :event_subtype, - :payload_uuid, :request_id, CAST(:payload AS jsonb), :payload_hash, - :idempotency_key, :delivery_status, :source, :created_at, :created_at - ) - `, rows[start:end]) - if err != nil { - return err - } - inserted, err := result.RowsAffected() + inserted, err := tx.codeSessionInboundEventMapper.InsertCodeSessionInboundEvents( + ctx, + rows[start:end], + ) if err != nil { return err } @@ -342,15 +336,16 @@ func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvents( return ErrInvalidState } } - updated, err := namedExecRowsAffected(ctx, tx.tx, ` - update code_sessions - set last_inbound_sequence_num = :sequence_num, updated_at = :now - where uuid = :uuid - `, map[string]any{ - "sequence_num": lastSequence, - "now": time.Now().UTC(), - "uuid": dbUUID(codeSession.UUID), - }) + codeSessionUUID, err := parseDBUUID("code_session_uuid", codeSession.UUID) + if err != nil { + return err + } + updated, err := tx.codeSessionMapper.UpdateCodeSessionInboundSequence( + ctx, + codeSessionUUID, + lastSequence, + time.Now().UTC(), + ) if err != nil { return err } @@ -362,39 +357,35 @@ func (tx ManagedAgentActivationTx) AppendCodeSessionInboundEvents( func listExistingActivationInboundEvents( ctx context.Context, - database sqlxNamedQueryer, + codeSessionInboundEventMapper CodeSessionInboundEventMapper, codeSession CodeSession, inputs []AppendCodeSessionEventInput, ) (map[string]struct{}, error) { - idempotencyKeys := make([]string, 0, len(inputs)) - seen := make(map[string]struct{}, len(inputs)) - for _, input := range inputs { - if input.IdempotencyKey == "" { - continue - } - if _, ok := seen[input.IdempotencyKey]; ok { - continue - } - seen[input.IdempotencyKey] = struct{}{} - idempotencyKeys = append(idempotencyKeys, input.IdempotencyKey) - } + idempotencyKeys := lo.Uniq(lo.FilterMap( + inputs, + func(input AppendCodeSessionEventInput, _ int) (string, bool) { + return input.IdempotencyKey, input.IdempotencyKey != "" + }, + )) if len(idempotencyKeys) == 0 { return map[string]struct{}{}, nil } - var rows []codeSessionInboundEventIdentityRow - if err := namedSelectContext(ctx, database, &rows, ` - select code_session_external_id, idempotency_key - from code_session_inbound_events - where organization_uuid = :organization_uuid - and workspace_uuid = :workspace_uuid - and idempotency_key = any(:idempotency_keys) - and deleted_at is null - `, map[string]any{ - "organization_uuid": dbUUID(codeSession.OrganizationUUID), - "workspace_uuid": dbUUID(codeSession.WorkspaceUUID), - "idempotency_keys": idempotencyKeys, - }); err != nil { + organizationUUID, err := parseDBUUID("organization_uuid", codeSession.OrganizationUUID) + if err != nil { + return nil, err + } + workspaceUUID, err := parseDBUUID("workspace_uuid", codeSession.WorkspaceUUID) + if err != nil { + return nil, err + } + rows, err := codeSessionInboundEventMapper.ListExistingActivationInboundEvents( + ctx, + organizationUUID, + workspaceUUID, + idempotencyKeys, + ) + if err != nil { return nil, err } existing := make(map[string]struct{}, len(rows)) @@ -478,16 +469,11 @@ func (tx ManagedAgentActivationTx) ActivateCodeSession( codeSessionUUID string, now time.Time, ) (bool, error) { - updated, err := namedExecRowsAffected(ctx, tx.tx, ` - update code_sessions - set status = 'active', updated_at = :now - where uuid = :uuid - and status = 'initializing' - and deleted_at is null - `, map[string]any{ - "uuid": dbUUID(codeSessionUUID), - "now": now, - }) + parsedUUID, err := parseDBUUID("code_session_uuid", codeSessionUUID) + if err != nil { + return false, err + } + updated, err := tx.codeSessionMapper.ActivateCodeSession(ctx, parsedUUID, now) if err != nil { return false, err } diff --git a/internal/db/db.go b/internal/db/db.go index b0b800cf..10fd0be7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -18,6 +18,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" + "github.com/superduck-ai/yourbatis" ) var ( @@ -39,8 +40,9 @@ var ( ) type DB struct { - Pool *pgxpool.Pool - sql *sqlx.DB + Pool *pgxpool.Pool + sql *sqlx.DB + mapperDB *yourbatis.DB } type APIKey struct { @@ -194,9 +196,11 @@ func Open(ctx context.Context, cfg config.Config) (*DB, error) { } func newDB(pool *pgxpool.Pool) *DB { + database := newSQLXDB(pool) return &DB{ - Pool: pool, - sql: newSQLXDB(pool), + Pool: pool, + sql: database, + mapperDB: yourbatis.NewDB(database.DB, yourbatis.DialectPostgres), } } diff --git a/internal/db/deployments.go b/internal/db/deployments.go index 505c72d6..389b693e 100644 --- a/internal/db/deployments.go +++ b/internal/db/deployments.go @@ -280,12 +280,12 @@ func (d *DB) CreateManualDeploymentRun(ctx context.Context, input CreateManualDe if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } - shouldEnqueue, err := shouldQueueForStartup(ctx, tx, session) + shouldEnqueue, err := shouldQueueForStartupSQLX(ctx, tx, session) if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } if shouldEnqueue { - if err := enqueueSessionEventsTx(ctx, tx, session, events); err != nil { + if err := enqueueSessionEventsSQLXTx(ctx, tx, session, events); err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } } diff --git a/internal/db/environment_work_mapper.go b/internal/db/environment_work_mapper.go new file mode 100644 index 00000000..05a6f473 --- /dev/null +++ b/internal/db/environment_work_mapper.go @@ -0,0 +1,19 @@ +package db + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper EnvironmentWorkMapper -sql ./environment_work_mapper.xml -dialect postgres + +// EnvironmentWorkMapper contains queries whose primary table is environment_work. +type EnvironmentWorkMapper interface { + StartupEnvironmentWorkExists( + ctx context.Context, + workspaceUUID uuid.UUID, + environmentUUID uuid.UUID, + sessionExternalID string, + ) (bool, error) +} diff --git a/internal/db/environment_work_mapper.xml b/internal/db/environment_work_mapper.xml new file mode 100644 index 00000000..c5a067e8 --- /dev/null +++ b/internal/db/environment_work_mapper.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/internal/db/managed_agent_activation.go b/internal/db/managed_agent_activation.go index d1c2f9ba..db3f3efb 100644 --- a/internal/db/managed_agent_activation.go +++ b/internal/db/managed_agent_activation.go @@ -3,13 +3,17 @@ package db import ( "context" - "github.com/jmoiron/sqlx" + "github.com/superduck-ai/yourbatis" ) // ManagedAgentActivationTx exposes the resource-scoped SQL operations used by // the code-session service to atomically hand off startup events. type ManagedAgentActivationTx struct { - tx *sqlx.Tx + codeSessionMapper CodeSessionMapper + codeSessionInboundEventMapper CodeSessionInboundEventMapper + sessionMapper SessionMapper + sessionEventMapper SessionEventMapper + sessionEventQueueMapper SessionEventQueueMapper } // WithManagedAgentActivationTx owns the database transaction lifecycle while @@ -18,14 +22,13 @@ func (d *DB) WithManagedAgentActivationTx( ctx context.Context, fn func(ManagedAgentActivationTx) error, ) error { - tx, err := d.sql.BeginTxx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - if err := fn(ManagedAgentActivationTx{tx: tx}); err != nil { - return err - } - return tx.Commit() + return d.mapperDB.Transaction(ctx, func(executor yourbatis.Executor) error { + return fn(ManagedAgentActivationTx{ + codeSessionMapper: NewCodeSessionMapper(executor), + codeSessionInboundEventMapper: NewCodeSessionInboundEventMapper(executor), + sessionMapper: NewSessionMapper(executor), + sessionEventMapper: NewSessionEventMapper(executor), + sessionEventQueueMapper: NewSessionEventQueueMapper(executor), + }) + }) } diff --git a/internal/db/session_event_mapper.go b/internal/db/session_event_mapper.go new file mode 100644 index 00000000..4a2bb371 --- /dev/null +++ b/internal/db/session_event_mapper.go @@ -0,0 +1,30 @@ +package db + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper SessionEventMapper -sql ./session_event_mapper.xml -dialect postgres + +// SessionEventMapper contains queries whose primary table is session_events. +type SessionEventMapper interface { + InsertSessionEvent( + ctx context.Context, + row sessionEventInsertRow, + ) (sessionEventRow, error) + + ListSessionEventsForActivation( + ctx context.Context, + organizationUUID uuid.UUID, + workspaceUUID uuid.UUID, + sessionUUID uuid.UUID, + ) ([]sessionEventRow, error) + + ListSessionEventsByUUIDs( + ctx context.Context, + sessionUUID uuid.UUID, + sessionEventUUIDs []uuid.UUID, + ) ([]sessionEventRow, error) +} diff --git a/internal/db/session_event_mapper.xml b/internal/db/session_event_mapper.xml new file mode 100644 index 00000000..5173afb6 --- /dev/null +++ b/internal/db/session_event_mapper.xml @@ -0,0 +1,80 @@ + + + + + + uuid, + external_id, + organization_uuid, + workspace_uuid, + session_uuid, + session_external_id, + thread_uuid, + thread_external_id, + event_type, + payload, + processed_at, + created_at, + deleted_at + + + + INSERT INTO session_events ( + uuid, + external_id, + organization_uuid, + workspace_uuid, + session_uuid, + session_external_id, + thread_uuid, + thread_external_id, + event_type, + payload, + processed_at, + created_at + ) VALUES ( + #{row.UUID}, + #{row.ExternalID}, + #{row.OrganizationUUID}, + #{row.WorkspaceUUID}, + #{row.SessionUUID}, + #{row.SessionExternalID}, + #{row.ThreadUUID}, + #{row.ThreadExternalID}, + #{row.EventType}, + CAST(#{row.Payload} AS jsonb), + #{row.ProcessedAt}, + #{row.CreatedAt} + ) + RETURNING + + + + + + + diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go index bcb60c86..21215036 100644 --- a/internal/db/session_event_queue.go +++ b/internal/db/session_event_queue.go @@ -2,14 +2,13 @@ package db import ( "context" - "database/sql" "encoding/json" - "errors" "fmt" + "time" "github.com/google/uuid" - "github.com/jmoiron/sqlx" "github.com/samber/lo" + "github.com/superduck-ai/yourbatis" ) type SessionEventDelivery string @@ -31,6 +30,28 @@ type sessionEventQueueInsertRow struct { SessionEventUUID uuid.UUID `db:"session_event_uuid"` } +type sessionUUIDs struct { + OrganizationUUID uuid.UUID + WorkspaceUUID uuid.UUID + SessionUUID uuid.UUID + EnvironmentUUID uuid.UUID +} + +type sessionEventInsertRow struct { + UUID uuid.UUID `db:"uuid"` + ExternalID string `db:"external_id"` + OrganizationUUID uuid.UUID `db:"organization_uuid"` + WorkspaceUUID uuid.UUID `db:"workspace_uuid"` + SessionUUID uuid.UUID `db:"session_uuid"` + SessionExternalID string `db:"session_external_id"` + ThreadUUID uuid.UUID `db:"thread_uuid"` + ThreadExternalID string `db:"thread_external_id"` + EventType string `db:"event_type"` + Payload []byte `db:"payload"` + ProcessedAt time.Time `db:"processed_at"` + CreatedAt time.Time `db:"created_at"` +} + // AppendSessionEventsForDelivery keeps the existing delivery path outside the // managed-agent startup window. During startup it accepts exactly one // user.message with an empty queue and records the public event and temporary @@ -42,71 +63,188 @@ func (d *DB) AppendSessionEventsForDelivery( events []SessionEvent, outcomeEvaluations json.RawMessage, ) ([]SessionEvent, SessionEventDelivery, error) { - tx, err := d.sql.BeginTxx(ctx, nil) + var created []SessionEvent + delivery := SessionEventDeliveryRealtime + err := d.mapperDB.Transaction(ctx, func(executor yourbatis.Executor) error { + sessionMapper := NewSessionMapper(executor) + sessionThreadMapper := NewSessionThreadMapper(executor) + sessionEventMapper := NewSessionEventMapper(executor) + sessionEventQueueMapper := NewSessionEventQueueMapper(executor) + codeSessionMapper := NewCodeSessionMapper(executor) + environmentWorkMapper := NewEnvironmentWorkMapper(executor) + parsedWorkspaceUUID, err := parseDBUUID("workspace_uuid", workspaceUUID) + if err != nil { + return err + } + sessionRow, found, err := sessionMapper.LockSessionForEvents( + ctx, + parsedWorkspaceUUID, + sessionExternalID, + ) + if err != nil { + return err + } + if !found { + return ErrNotFound + } + session := sessionRow.session() + if session.ArchivedAt != nil { + return ErrSessionArchived + } + userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { + return event.EventType == "user.message" + }) + shouldEnqueue := false + if userMessageCount > 0 { + shouldEnqueue, err = shouldQueueForStartup( + ctx, + codeSessionMapper, + environmentWorkMapper, + session, + ) + if err != nil { + return err + } + } + if shouldEnqueue { + if len(events) != 1 || userMessageCount != 1 { + return ErrSessionStartupMessageConflict + } + sessionUUID, err := parseDBUUID("session_uuid", session.UUID) + if err != nil { + return err + } + hasQueuedEvents, err := sessionEventQueueMapper.SessionEventQueueExists(ctx, sessionUUID) + if err != nil { + return err + } + if hasQueuedEvents { + return ErrSessionStartupMessageConflict + } + } + + created, err = insertSessionEventsWithMappers( + ctx, + sessionThreadMapper, + sessionEventMapper, + session, + events, + ) + if err != nil { + return err + } + if shouldEnqueue { + if err := enqueueSessionEventsTx( + ctx, + sessionEventQueueMapper, + session, + created, + ); err != nil { + return err + } + delivery = SessionEventDeliveryStartupQueued + } + if len(outcomeEvaluations) > 0 { + updated, err := sessionMapper.SetSessionOutcomeEvaluations( + ctx, + parsedWorkspaceUUID, + session.ExternalID, + outcomeEvaluations, + ) + if err != nil { + return err + } + if updated == 0 { + return ErrNotFound + } + if updated != 1 { + return ErrInvalidState + } + } + return nil + }) if err != nil { return nil, "", err } - defer tx.Rollback() + return created, delivery, nil +} - session, err := getSessionSQLX( +func insertSessionEventsWithMappers( + ctx context.Context, + sessionThreadMapper SessionThreadMapper, + sessionEventMapper SessionEventMapper, + session Session, + events []SessionEvent, +) ([]SessionEvent, error) { + parsedUUIDs, err := parseSessionUUIDs(session) + if err != nil { + return nil, err + } + primaryRow, found, err := sessionThreadMapper.GetPrimarySessionThread( ctx, - tx, - lockSessionForEventsQuery, - sessionLookupArguments(workspaceUUID, sessionExternalID), + parsedUUIDs.WorkspaceUUID, + session.ExternalID, ) if err != nil { - return nil, "", err - } - if session.ArchivedAt != nil { - return nil, "", ErrSessionArchived + return nil, err } - userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { - return event.EventType == "user.message" - }) - shouldEnqueue := false - if userMessageCount > 0 { - shouldEnqueue, err = shouldQueueForStartup(ctx, tx, session) - if err != nil { - return nil, "", err - } + if !found { + return nil, ErrNotFound } - if shouldEnqueue { - if len(events) != 1 || userMessageCount != 1 { - return nil, "", ErrSessionStartupMessageConflict + primary := primaryRow.thread() + + created := make([]SessionEvent, 0, len(events)) + for _, input := range events { + event := input + event.OrganizationUUID = session.OrganizationUUID + event.WorkspaceUUID = session.WorkspaceUUID + event.SessionUUID = session.UUID + event.SessionExternalID = session.ExternalID + + thread := primary + if event.ThreadExternalID != nil { + threadRow, found, err := sessionThreadMapper.GetSessionThreadByExternalID( + ctx, + parsedUUIDs.WorkspaceUUID, + session.ExternalID, + *event.ThreadExternalID, + ) + if err != nil { + return nil, err + } + if !found { + return nil, ErrNotFound + } + thread = threadRow.thread() } - hasQueuedEvents, err := sessionEventQueueExists(ctx, tx, session) + threadUUID, err := parseDBUUID("thread_uuid", thread.UUID) if err != nil { - return nil, "", err - } - if hasQueuedEvents { - return nil, "", ErrSessionStartupMessageConflict + return nil, err } - } - - created, err := insertSessionEventsSQLXTx(ctx, tx, session, events, false) - if err != nil { - return nil, "", err - } - delivery := SessionEventDeliveryRealtime - if shouldEnqueue { - if err := enqueueSessionEventsTx(ctx, tx, session, created); err != nil { - return nil, "", err + eventUUID, err := parseDBUUID("event_uuid", event.UUID) + if err != nil { + return nil, err } - delivery = SessionEventDeliveryStartupQueued - } - if len(outcomeEvaluations) > 0 { - if _, err := getSessionSQLX(ctx, tx, setSessionOutcomeEvaluationsQuery, map[string]any{ - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "session_external_id": session.ExternalID, - "outcome_evaluations": jsonArg(outcomeEvaluations), - }); err != nil { - return nil, "", err + row, err := sessionEventMapper.InsertSessionEvent(ctx, sessionEventInsertRow{ + UUID: eventUUID, + ExternalID: event.ExternalID, + OrganizationUUID: parsedUUIDs.OrganizationUUID, + WorkspaceUUID: parsedUUIDs.WorkspaceUUID, + SessionUUID: parsedUUIDs.SessionUUID, + SessionExternalID: session.ExternalID, + ThreadUUID: threadUUID, + ThreadExternalID: thread.ExternalID, + EventType: event.EventType, + Payload: []byte(event.Payload), + ProcessedAt: event.ProcessedAt, + CreatedAt: event.CreatedAt, + }) + if err != nil { + return nil, err } + created = append(created, row.event()) } - if err := tx.Commit(); err != nil { - return nil, "", err - } - return created, delivery, nil + return created, nil } // ListSessionEventQueueItems returns and locks the public events currently @@ -118,15 +256,25 @@ func (tx ManagedAgentActivationTx) ListSessionEventQueueItems( ctx context.Context, session Session, ) ([]SessionEvent, error) { - return listSessionEventQueueItems(ctx, tx.tx, session) + return listSessionEventQueueItems( + ctx, + tx.sessionEventQueueMapper, + tx.sessionEventMapper, + session, + ) } func listSessionEventQueueItems( ctx context.Context, - database sqlxNamedQueryer, + sessionEventQueueMapper SessionEventQueueMapper, + sessionEventMapper SessionEventMapper, session Session, ) ([]SessionEvent, error) { - identityRows, err := listSessionEventQueueIdentityRows(ctx, database, session) + sessionUUID, err := parseDBUUID("session_uuid", session.UUID) + if err != nil { + return nil, err + } + identityRows, err := sessionEventQueueMapper.ListSessionEventQueueIdentities(ctx, sessionUUID) if err != nil { return nil, err } @@ -134,7 +282,7 @@ func listSessionEventQueueItems( return nil, nil } - eventsByUUID, err := sessionEventsByUUIDs(ctx, database, session, identityRows) + eventsByUUID, err := sessionEventsByUUIDs(ctx, sessionEventMapper, session, identityRows) if err != nil { return nil, err } @@ -162,44 +310,48 @@ func (tx ManagedAgentActivationTx) ListSessionEventsForActivation( ctx context.Context, session Session, ) ([]SessionEvent, error) { - return listSessionEventsSQLX(ctx, tx.tx, ` - select `+sessionEventSQLXColumns+` - from session_events - where organization_uuid = :organization_uuid - and workspace_uuid = :workspace_uuid - and session_uuid = :session_uuid - and deleted_at is null - order by created_at asc, id asc - `, map[string]any{ - "organization_uuid": dbUUID(session.OrganizationUUID), - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "session_uuid": dbUUID(session.UUID), - }) + parsedUUIDs, err := parseSessionUUIDs(session) + if err != nil { + return nil, err + } + rows, err := tx.sessionEventMapper.ListSessionEventsForActivation( + ctx, + parsedUUIDs.OrganizationUUID, + parsedUUIDs.WorkspaceUUID, + parsedUUIDs.SessionUUID, + ) + if err != nil { + return nil, err + } + return lo.Map(rows, func(row sessionEventRow, _ int) SessionEvent { + return row.event() + }), nil } func sessionEventsByUUIDs( ctx context.Context, - database sqlxNamedQueryer, + sessionEventMapper SessionEventMapper, session Session, identityRows []sessionEventQueueIdentityRow, ) (map[string]SessionEvent, error) { - eventUUIDs := make([]string, len(identityRows)) - for i, row := range identityRows { - eventUUIDs[i] = row.SessionEventUUID.String() - } - events, err := listSessionEventsSQLX(ctx, database, ` - select `+sessionEventSQLXColumns+` - from session_events - where uuid = any(:session_event_uuids) - and session_uuid = :session_uuid - and deleted_at is null - `, map[string]any{ - "session_event_uuids": eventUUIDs, - "session_uuid": dbUUID(session.UUID), + eventUUIDs := lo.Map(identityRows, func(row sessionEventQueueIdentityRow, _ int) uuid.UUID { + return row.SessionEventUUID }) + sessionUUID, err := parseDBUUID("session_uuid", session.UUID) if err != nil { return nil, err } + rows, err := sessionEventMapper.ListSessionEventsByUUIDs( + ctx, + sessionUUID, + eventUUIDs, + ) + if err != nil { + return nil, err + } + events := lo.Map(rows, func(row sessionEventRow, _ int) SessionEvent { + return row.event() + }) byUUID := make(map[string]SessionEvent, len(events)) for _, event := range events { byUUID[event.UUID] = event @@ -207,37 +359,15 @@ func sessionEventsByUUIDs( return byUUID, nil } -func listSessionEventQueueIdentityRows( - ctx context.Context, - database sqlxNamedQueryer, - session Session, -) ([]sessionEventQueueIdentityRow, error) { - // session_uuid uniquely identifies the public Session; tenant columns are - // written on insert but are not required as query predicates. - query := ` - select q.id, q.session_event_uuid - from session_event_queue q - where q.session_uuid = :session_uuid - order by q.id asc - for update of q - ` - var rows []sessionEventQueueIdentityRow - err := namedSelectContext(ctx, database, &rows, query, map[string]any{ - "session_uuid": dbUUID(session.UUID), - }) - if err != nil { - return nil, err - } - return rows, nil -} - func (tx ManagedAgentActivationTx) DeleteSessionEventQueue( ctx context.Context, sessionUUID string, ) error { - _, err := namedExecRowsAffected(ctx, tx.tx, deleteSessionEventQueueQuery, map[string]any{ - "session_uuid": dbUUID(sessionUUID), - }) + parsedUUID, err := parseDBUUID("session_uuid", sessionUUID) + if err != nil { + return err + } + _, err = tx.sessionEventQueueMapper.DeleteSessionEventQueue(ctx, parsedUUID) return err } @@ -247,83 +377,36 @@ func (tx ManagedAgentActivationTx) DeleteSessionEventQueue( // has session-scoped environment work in flight. func shouldQueueForStartup( ctx context.Context, - database sqlxNamedQueryer, + codeSessionMapper CodeSessionMapper, + environmentWorkMapper EnvironmentWorkMapper, session Session, ) (bool, error) { - var status string - err := namedGetContext(ctx, database, &status, ` - select status - from code_sessions - where session_uuid = :session_uuid - and deleted_at is null - order by created_at desc, uuid desc - limit 1 - `, map[string]any{ - "session_uuid": dbUUID(session.UUID), - }) - if err == nil && status != "initializing" { - return false, nil - } - if err != nil && !errors.Is(err, sql.ErrNoRows) { + parsedUUIDs, err := parseSessionUUIDs(session) + if err != nil { return false, err } - - var startupWorkExists bool - err = namedGetContext(ctx, database, &startupWorkExists, ` - select exists ( - select 1 - from environment_work ew - where ew.workspace_uuid = :workspace_uuid - and ew.environment_uuid = :environment_uuid - and ew.data->>'type' = 'session' - and ew.data->>'id' = :session_external_id - and ew.state in ('queued', 'starting', 'active') - and ew.deleted_at is null - ) - `, map[string]any{ - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "environment_uuid": dbUUID(session.EnvironmentUUID), - "session_external_id": session.ExternalID, - }) + status, found, err := codeSessionMapper.GetLatestCodeSessionStatus(ctx, parsedUUIDs.SessionUUID) if err != nil { return false, err } - return startupWorkExists, nil -} - -func sessionEventQueueExists( - ctx context.Context, - database sqlxNamedQueryer, - session Session, -) (bool, error) { - var exists bool - err := namedGetContext(ctx, database, &exists, ` - select exists ( - select 1 - from session_event_queue q - where q.session_uuid = :session_uuid - ) - `, map[string]any{ - "session_uuid": dbUUID(session.UUID), - }) - return exists, err + if found && status != "initializing" { + return false, nil + } + return environmentWorkMapper.StartupEnvironmentWorkExists( + ctx, + parsedUUIDs.WorkspaceUUID, + parsedUUIDs.EnvironmentUUID, + session.ExternalID, + ) } func enqueueSessionEventsTx( ctx context.Context, - tx *sqlx.Tx, + sessionEventQueueMapper SessionEventQueueMapper, session Session, events []SessionEvent, ) error { - organizationUUID, err := parseDBUUID("organization_uuid", session.OrganizationUUID) - if err != nil { - return err - } - workspaceUUID, err := parseDBUUID("workspace_uuid", session.WorkspaceUUID) - if err != nil { - return err - } - sessionUUID, err := parseDBUUID("session_uuid", session.UUID) + parsedUUIDs, err := parseSessionUUIDs(session) if err != nil { return err } @@ -338,9 +421,9 @@ func enqueueSessionEventsTx( return err } rows = append(rows, sessionEventQueueInsertRow{ - OrganizationUUID: organizationUUID, - WorkspaceUUID: workspaceUUID, - SessionUUID: sessionUUID, + OrganizationUUID: parsedUUIDs.OrganizationUUID, + WorkspaceUUID: parsedUUIDs.WorkspaceUUID, + SessionUUID: parsedUUIDs.SessionUUID, SessionEventUUID: eventUUID, }) } @@ -348,16 +431,31 @@ func enqueueSessionEventsTx( return nil } - _, err = tx.NamedExecContext(ctx, ` - insert into session_event_queue ( - organization_uuid, workspace_uuid, session_uuid, session_event_uuid - ) - values ( - :organization_uuid, - :workspace_uuid, - :session_uuid, - :session_event_uuid - ) - `, rows) + _, err = sessionEventQueueMapper.EnqueueSessionEvents(ctx, rows) return err } + +func parseSessionUUIDs(session Session) (sessionUUIDs, error) { + organizationUUID, err := parseDBUUID("organization_uuid", session.OrganizationUUID) + if err != nil { + return sessionUUIDs{}, err + } + workspaceUUID, err := parseDBUUID("workspace_uuid", session.WorkspaceUUID) + if err != nil { + return sessionUUIDs{}, err + } + sessionUUID, err := parseDBUUID("session_uuid", session.UUID) + if err != nil { + return sessionUUIDs{}, err + } + environmentUUID, err := parseDBUUID("environment_uuid", session.EnvironmentUUID) + if err != nil { + return sessionUUIDs{}, err + } + return sessionUUIDs{ + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + SessionUUID: sessionUUID, + EnvironmentUUID: environmentUUID, + }, nil +} diff --git a/internal/db/session_event_queue_mapper.go b/internal/db/session_event_queue_mapper.go new file mode 100644 index 00000000..b2cd8bbe --- /dev/null +++ b/internal/db/session_event_queue_mapper.go @@ -0,0 +1,33 @@ +package db + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper SessionEventQueueMapper -sql ./session_event_queue_mapper.xml -dialect postgres + +// SessionEventQueueMapper contains queries whose primary table is +// session_event_queue. +type SessionEventQueueMapper interface { + ListSessionEventQueueIdentities( + ctx context.Context, + sessionUUID uuid.UUID, + ) ([]sessionEventQueueIdentityRow, error) + + DeleteSessionEventQueue( + ctx context.Context, + sessionUUID uuid.UUID, + ) (int64, error) + + SessionEventQueueExists( + ctx context.Context, + sessionUUID uuid.UUID, + ) (bool, error) + + EnqueueSessionEvents( + ctx context.Context, + rows []sessionEventQueueInsertRow, + ) (int64, error) +} diff --git a/internal/db/session_event_queue_mapper.xml b/internal/db/session_event_queue_mapper.xml new file mode 100644 index 00000000..942337e5 --- /dev/null +++ b/internal/db/session_event_queue_mapper.xml @@ -0,0 +1,47 @@ + + + + + + + + DELETE FROM session_event_queue + WHERE session_uuid = #{sessionUUID} + + + + + + INSERT INTO session_event_queue ( + organization_uuid, + workspace_uuid, + session_uuid, + session_event_uuid + ) VALUES + + ( + #{row.OrganizationUUID}, + #{row.WorkspaceUUID}, + #{row.SessionUUID}, + #{row.SessionEventUUID} + ) + + + diff --git a/internal/db/session_event_queue_sqlx.go b/internal/db/session_event_queue_sqlx.go new file mode 100644 index 00000000..f949ebe9 --- /dev/null +++ b/internal/db/session_event_queue_sqlx.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "database/sql" + "errors" +) + +const ( + deleteSessionEventQueueQuery = ` + delete from session_event_queue + where session_uuid = :session_uuid + ` + latestCodeSessionStatusForStartupQuery = ` + select status + from code_sessions + where session_uuid = :session_uuid + and deleted_at is null + order by created_at desc, uuid desc + limit 1 + ` + startupEnvironmentWorkExistsQuery = ` + select exists ( + select 1 + from environment_work ew + where ew.workspace_uuid = :workspace_uuid + and ew.environment_uuid = :environment_uuid + and ew.data->>'type' = 'session' + and ew.data->>'id' = :session_external_id + and ew.state in ('queued', 'starting', 'active') + and ew.deleted_at is null + ) as exists + ` + enqueueSessionEventQuery = ` + insert into session_event_queue ( + organization_uuid, workspace_uuid, session_uuid, session_event_uuid + ) values ( + :organization_uuid, :workspace_uuid, :session_uuid, :session_event_uuid + ) + ` +) + +// These sqlx helpers are intentionally limited to legacy transactions whose +// other statements have not moved to yourbatis. Keeping the queue operation on +// the same sqlx transaction preserves atomicity without adapting sqlx.Tx into a +// custom yourbatis Executor. +func deleteSessionEventQueueSQLX( + ctx context.Context, + database sqlxNamedExecer, + sessionUUID string, +) error { + _, err := namedExecContext(ctx, database, deleteSessionEventQueueQuery, map[string]any{ + "session_uuid": dbUUID(sessionUUID), + }) + return err +} + +func shouldQueueForStartupSQLX( + ctx context.Context, + database sqlxNamedQueryer, + session Session, +) (bool, error) { + arguments := map[string]any{ + "session_uuid": dbUUID(session.UUID), + "workspace_uuid": dbUUID(session.WorkspaceUUID), + "environment_uuid": dbUUID(session.EnvironmentUUID), + "session_external_id": session.ExternalID, + } + var status string + err := namedGetContext(ctx, database, &status, latestCodeSessionStatusForStartupQuery, arguments) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err + } + if err == nil && status != "initializing" { + return false, nil + } + + var exists bool + err = namedGetContext(ctx, database, &exists, startupEnvironmentWorkExistsQuery, arguments) + return exists, err +} + +func enqueueSessionEventsSQLXTx( + ctx context.Context, + database sqlxNamedExecer, + session Session, + events []SessionEvent, +) error { + for _, event := range events { + if event.EventType != "user.message" { + continue + } + _, err := namedExecContext(ctx, database, enqueueSessionEventQuery, map[string]any{ + "organization_uuid": dbUUID(session.OrganizationUUID), + "workspace_uuid": dbUUID(session.WorkspaceUUID), + "session_uuid": dbUUID(session.UUID), + "session_event_uuid": dbUUID(event.UUID), + }) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/db/session_mapper.go b/internal/db/session_mapper.go new file mode 100644 index 00000000..101fdff6 --- /dev/null +++ b/internal/db/session_mapper.go @@ -0,0 +1,25 @@ +package db + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper SessionMapper -sql ./session_mapper.xml -dialect postgres + +// SessionMapper contains queries whose primary table is sessions. +type SessionMapper interface { + LockSessionForEvents( + ctx context.Context, + workspaceUUID uuid.UUID, + sessionExternalID string, + ) (sessionRow, bool, error) + + SetSessionOutcomeEvaluations( + ctx context.Context, + workspaceUUID uuid.UUID, + sessionExternalID string, + outcomeEvaluations []byte, + ) (int64, error) +} diff --git a/internal/db/session_mapper.xml b/internal/db/session_mapper.xml new file mode 100644 index 00000000..2fc9f159 --- /dev/null +++ b/internal/db/session_mapper.xml @@ -0,0 +1,48 @@ + + + + uuid, + external_id, + organization_uuid, + workspace_uuid, + created_by_api_key_uuid, + environment_uuid, + environment_external_id, + agent_uuid, + agent_external_id, + agent_version, + agent_snapshot, + deployment_uuid, + deployment_external_id, + title, + metadata, + vault_ids, + status, + usage, + stats, + outcome_evaluations, + created_at, + updated_at, + archived_at, + deleted_at + + + + + + UPDATE sessions + SET outcome_evaluations = CAST(#{outcomeEvaluations} AS jsonb), + updated_at = NOW() + WHERE workspace_uuid = #{workspaceUUID} + AND external_id = #{sessionExternalID} + AND deleted_at IS NULL + + diff --git a/internal/db/session_thread_mapper.go b/internal/db/session_thread_mapper.go new file mode 100644 index 00000000..84ca56ae --- /dev/null +++ b/internal/db/session_thread_mapper.go @@ -0,0 +1,25 @@ +package db + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go tool sqlmapgen -mapper SessionThreadMapper -sql ./session_thread_mapper.xml -dialect postgres + +// SessionThreadMapper contains queries whose primary table is session_threads. +type SessionThreadMapper interface { + GetPrimarySessionThread( + ctx context.Context, + workspaceUUID uuid.UUID, + sessionExternalID string, + ) (sessionThreadRow, bool, error) + + GetSessionThreadByExternalID( + ctx context.Context, + workspaceUUID uuid.UUID, + sessionExternalID string, + threadExternalID string, + ) (sessionThreadRow, bool, error) +} diff --git a/internal/db/session_thread_mapper.xml b/internal/db/session_thread_mapper.xml new file mode 100644 index 00000000..a5feaed9 --- /dev/null +++ b/internal/db/session_thread_mapper.xml @@ -0,0 +1,47 @@ + + + + + + uuid, + external_id, + organization_uuid, + workspace_uuid, + session_uuid, + session_external_id, + parent_thread_uuid, + parent_thread_external_id, + agent_snapshot, + status, + usage, + stats, + created_at, + updated_at, + archived_at, + deleted_at + + + + + + diff --git a/internal/db/sessions.go b/internal/db/sessions.go index 3374d60b..e6a8fe57 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -329,8 +329,7 @@ func (d *DB) DeleteSession(ctx context.Context, workspaceUUID string, externalID if _, err := namedExecContext(ctx, tx, deleteSessionResourcesQuery, arguments); err != nil { return Session{}, err } - arguments["session_uuid"] = dbUUID(session.UUID) - if _, err := namedExecContext(ctx, tx, deleteSessionEventQueueQuery, arguments); err != nil { + if err := deleteSessionEventQueueSQLX(ctx, tx, session.UUID); err != nil { return Session{}, err } if _, err := namedExecContext(ctx, tx, deleteSessionEventsQuery, arguments); err != nil { diff --git a/internal/db/sessions_migration_sqlx_test.go b/internal/db/sessions_migration_sqlx_test.go index 4c43ed62..f6c47dd2 100644 --- a/internal/db/sessions_migration_sqlx_test.go +++ b/internal/db/sessions_migration_sqlx_test.go @@ -112,12 +112,6 @@ func TestMigratedSessionQueriesBindNamedArguments(t *testing.T) { arguments: sessionLookupArguments("00000000-0000-0000-0000-000000000002", "sesn_test"), wantArgCount: 2, }, - { - name: "delete session events", - query: deleteSessionEventsQuery, - arguments: sessionLookupArguments("00000000-0000-0000-0000-000000000002", "sesn_test"), - wantArgCount: 2, - }, { name: "delete session event queue", query: deleteSessionEventQueueQuery, @@ -126,6 +120,12 @@ func TestMigratedSessionQueriesBindNamedArguments(t *testing.T) { }, wantArgCount: 1, }, + { + name: "delete session events", + query: deleteSessionEventsQuery, + arguments: sessionLookupArguments("00000000-0000-0000-0000-000000000002", "sesn_test"), + wantArgCount: 2, + }, { name: "stop environment work", query: stopDeletedSessionEnvironmentWorkQuery, diff --git a/internal/db/sessions_sqlx.go b/internal/db/sessions_sqlx.go index a83bd74d..135df689 100644 --- a/internal/db/sessions_sqlx.go +++ b/internal/db/sessions_sqlx.go @@ -133,10 +133,6 @@ const ( and session_external_id = :session_external_id and deleted_at is null ` - deleteSessionEventQueueQuery = ` - delete from session_event_queue q - where q.session_uuid = :session_uuid - ` stopDeletedSessionEnvironmentWorkQuery = ` update environment_work set state = case when state in ('stopped') then state else 'stopping' end, @@ -358,12 +354,22 @@ func (tx ManagedAgentActivationTx) LockSessionForEvents( workspaceUUID string, sessionExternalID string, ) (Session, error) { - return getSessionSQLX( + parsedWorkspaceUUID, err := parseDBUUID("workspace_uuid", workspaceUUID) + if err != nil { + return Session{}, err + } + row, found, err := tx.sessionMapper.LockSessionForEvents( ctx, - tx.tx, - lockSessionForEventsQuery, - sessionLookupArguments(workspaceUUID, sessionExternalID), + parsedWorkspaceUUID, + sessionExternalID, ) + if err != nil { + return Session{}, err + } + if !found { + return Session{}, ErrNotFound + } + return row.session(), nil } func getSessionSQLX( diff --git a/internal/db/yourbatis_mappers_test.go b/internal/db/yourbatis_mappers_test.go new file mode 100644 index 00000000..e5f5439a --- /dev/null +++ b/internal/db/yourbatis_mappers_test.go @@ -0,0 +1,237 @@ +package db + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + yourbatis "github.com/superduck-ai/yourbatis" +) + +func TestTableMappersRejectInvalidUUIDBeforeExecution(t *testing.T) { + err := (ManagedAgentActivationTx{}).DeleteSessionEventQueue(context.Background(), "not-a-uuid") + if err == nil || !strings.Contains(err.Error(), "session_uuid must be a non-nil UUID") { + t.Fatalf("DeleteSessionEventQueue() error = %v", err) + } +} + +func TestTableMappersBuildDynamicQueries(t *testing.T) { + organizationUUID := uuid.MustParse("11111111-1111-4111-8111-111111111111") + workspaceUUID := uuid.MustParse("22222222-2222-4222-8222-222222222222") + sessionUUID := uuid.MustParse("33333333-3333-4333-8333-333333333333") + eventUUIDOne := uuid.MustParse("44444444-4444-4444-8444-444444444444") + eventUUIDTwo := uuid.MustParse("55555555-5555-4555-8555-555555555555") + + t.Run("idempotency keys", func(t *testing.T) { + bound := buildCodeSessionInboundEventMapperListExistingActivationInboundEvents( + yourbatis.DialectPostgres, + organizationUUID, + workspaceUUID, + []string{"idem-one", "idem-two"}, + ) + assertMapperSQLContains(t, bound, "idempotency_key IN ( $3 , $4 )") + assertMapperArgumentNames(t, bound, []string{ + "organizationUUID", + "workspaceUUID", + "idempotencyKey", + "idempotencyKey", + }) + }) + + t.Run("event UUIDs", func(t *testing.T) { + bound := buildSessionEventMapperListSessionEventsByUUIDs( + yourbatis.DialectPostgres, + sessionUUID, + []uuid.UUID{eventUUIDOne, eventUUIDTwo}, + ) + assertMapperSQLContains(t, bound, "uuid IN ( $1 , $2 )") + assertMapperSQLContains(t, bound, "session_uuid = $3") + assertMapperArgumentNames(t, bound, []string{ + "sessionEventUUID", + "sessionEventUUID", + "sessionUUID", + }) + }) + + t.Run("queue batch", func(t *testing.T) { + bound := buildSessionEventQueueMapperEnqueueSessionEvents( + yourbatis.DialectPostgres, + []sessionEventQueueInsertRow{ + { + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + SessionUUID: sessionUUID, + SessionEventUUID: eventUUIDOne, + }, + { + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + SessionUUID: sessionUUID, + SessionEventUUID: eventUUIDTwo, + }, + }, + ) + assertMapperSQLContains(t, bound, "( $1, $2, $3, $4 ) , ( $5, $6, $7, $8 )") + if len(bound.Args) != 8 { + t.Fatalf("queue batch argument count = %d, want 8", len(bound.Args)) + } + }) + + t.Run("queue exists", func(t *testing.T) { + bound := buildSessionEventQueueMapperSessionEventQueueExists( + yourbatis.DialectPostgres, + sessionUUID, + ) + assertMapperSQLEquals( + t, + bound, + "SELECT EXISTS ( SELECT 1 FROM session_event_queue WHERE session_uuid = $1 )", + ) + assertMapperArgumentNames(t, bound, []string{"sessionUUID"}) + }) + + t.Run("queue identities", func(t *testing.T) { + bound := buildSessionEventQueueMapperListSessionEventQueueIdentities( + yourbatis.DialectPostgres, + sessionUUID, + ) + assertMapperSQLEquals( + t, + bound, + "SELECT id, session_event_uuid FROM session_event_queue WHERE session_uuid = $1 ORDER BY id ASC FOR UPDATE", + ) + assertMapperArgumentNames(t, bound, []string{"sessionUUID"}) + }) +} + +func TestTableMappersBuildWrites(t *testing.T) { + organizationUUID := uuid.MustParse("11111111-1111-4111-8111-111111111111") + workspaceUUID := uuid.MustParse("22222222-2222-4222-8222-222222222222") + codeSessionUUID := uuid.MustParse("33333333-3333-4333-8333-333333333333") + sessionUUID := uuid.MustParse("44444444-4444-4444-8444-444444444444") + threadUUID := uuid.MustParse("55555555-5555-4555-8555-555555555555") + eventUUID := uuid.MustParse("66666666-6666-4666-8666-666666666666") + createdAt := time.Date(2026, time.August, 2, 12, 0, 0, 0, time.UTC) + row := codeSessionInboundEventInsertRow{ + ExternalID: "evt_test", + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + CodeSessionUUID: codeSessionUUID, + CodeSessionExternalID: "codeses_test", + SequenceNum: 1, + EventType: "user", + EventSubtype: "message", + Payload: []byte(`{"type":"user"}`), + PayloadHash: "hash", + IdempotencyKey: "idem", + DeliveryStatus: "queued", + Source: "session_event", + CreatedAt: createdAt, + } + bound := buildCodeSessionInboundEventMapperInsertCodeSessionInboundEvents( + yourbatis.DialectPostgres, + []codeSessionInboundEventInsertRow{row, row}, + ) + assertMapperSQLContains(t, bound, "CAST($11 AS jsonb)") + assertMapperSQLContains(t, bound, "CAST($28 AS jsonb)") + if len(bound.Args) != 34 { + t.Fatalf("inbound batch argument count = %d, want 34", len(bound.Args)) + } + + outcomeBound := buildSessionMapperSetSessionOutcomeEvaluations( + yourbatis.DialectPostgres, + workspaceUUID, + "sesn_test", + []byte(`[{"score":1}]`), + ) + assertMapperSQLContains(t, outcomeBound, "outcome_evaluations = CAST($1 AS jsonb)") + assertMapperSQLContains(t, outcomeBound, "workspace_uuid = $2") + assertMapperArgumentNames(t, outcomeBound, []string{ + "outcomeEvaluations", + "workspaceUUID", + "sessionExternalID", + }) + + threadBound := buildSessionThreadMapperGetSessionThreadByExternalID( + yourbatis.DialectPostgres, + workspaceUUID, + "sesn_test", + "sesthr_test", + ) + assertMapperSQLContains(t, threadBound, "external_id = $3") + assertMapperArgumentNames(t, threadBound, []string{ + "workspaceUUID", + "sessionExternalID", + "threadExternalID", + }) + + eventBound := buildSessionEventMapperInsertSessionEvent( + yourbatis.DialectPostgres, + sessionEventInsertRow{ + UUID: eventUUID, + ExternalID: "event_test", + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + SessionUUID: sessionUUID, + SessionExternalID: "sesn_test", + ThreadUUID: threadUUID, + ThreadExternalID: "sesthr_test", + EventType: "user.message", + Payload: []byte(`{"type":"user.message"}`), + ProcessedAt: createdAt, + CreatedAt: createdAt, + }, + ) + assertMapperSQLContains(t, eventBound, "CAST($10 AS jsonb)") + assertMapperArgumentNames(t, eventBound, []string{ + "row.UUID", + "row.ExternalID", + "row.OrganizationUUID", + "row.WorkspaceUUID", + "row.SessionUUID", + "row.SessionExternalID", + "row.ThreadUUID", + "row.ThreadExternalID", + "row.EventType", + "row.Payload", + "row.ProcessedAt", + "row.CreatedAt", + }) +} + +func assertMapperSQLContains( + t *testing.T, + bound yourbatis.BoundSQL, + want string, +) { + t.Helper() + compact := strings.Join(strings.Fields(bound.SQL), " ") + if !strings.Contains(compact, want) { + t.Fatalf("SQL does not contain %q:\n%s", want, compact) + } +} + +func assertMapperSQLEquals(t *testing.T, bound yourbatis.BoundSQL, want string) { + t.Helper() + got := strings.Join(strings.Fields(bound.SQL), " ") + if got != want { + t.Fatalf("SQL = %q, want %q", got, want) + } +} + +func assertMapperArgumentNames( + t *testing.T, + bound yourbatis.BoundSQL, + want []string, +) { + t.Helper() + got := make([]string, len(bound.Args)) + for index, argument := range bound.Args { + got[index] = argument.Name + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("argument names = %v, want %v", got, want) + } +} From 5ea177cefbc000e1b333630f69ee257804ff3159 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Mon, 3 Aug 2026 23:34:35 +0800 Subject: [PATCH 19/23] refactor(db): migrate single code-session event append to yourbatis Move inbound/outbound append onto mapperDB.Transaction with table mappers, keeping lock/epoch/idempotency/sequence semantics, rename activation entry to ActivateManagedAgentCodeSession, and update tests and design docs. --- docs/design/be/ccrv2/ccr-v2-epoch-design.md | 8 + .../be/session-startup-message-delivery.md | 6 +- .../managed_agent_code_session.go | 6 +- .../db/code_session_inbound_event_mapper.go | 11 + .../db/code_session_inbound_event_mapper.xml | 87 ++++++ internal/db/code_session_mapper.go | 12 + internal/db/code_session_mapper.xml | 16 ++ .../db/code_session_outbound_event_mapper.go | 24 ++ .../db/code_session_outbound_event_mapper.xml | 93 +++++++ internal/db/code_sessions.go | 248 ++++++++---------- internal/db/code_sessions_sqlx.go | 19 ++ internal/db/yourbatis_mappers_test.go | 55 ++++ tests/sessions_api_test.go | 78 +++++- 13 files changed, 515 insertions(+), 148 deletions(-) create mode 100644 internal/db/code_session_outbound_event_mapper.go create mode 100644 internal/db/code_session_outbound_event_mapper.xml diff --git a/docs/design/be/ccrv2/ccr-v2-epoch-design.md b/docs/design/be/ccrv2/ccr-v2-epoch-design.md index b8df2aa2..45be33af 100644 --- a/docs/design/be/ccrv2/ccr-v2-epoch-design.md +++ b/docs/design/be/ccrv2/ccr-v2-epoch-design.md @@ -164,6 +164,14 @@ update code_sessions sequence_num commit() ``` +该事务由共享的 `yourbatis.DB.Transaction` 创建;回调中的同一个事务 `Executor` +同时构造 `CodeSessionMapper`、`CodeSessionInboundEventMapper` 和 +`CodeSessionOutboundEventMapper`。`CodeSessionMapper.LockCodeSessionByExternalID` +负责日常 append 的行锁,不附加 `initializing` 状态限制;启动激活仍使用独立的 +`LockInitializingCodeSession`。方向对应的 event mapper 在锁内完成幂等单条查询和 +`INSERT ... RETURNING`,随后由 `CodeSessionMapper` 更新对应方向的 sequence。 +激活使用的 inbound 批量 insert 保持独立,不经过单条 append。 + 这样线性化语义才成立: - 如果旧 worker 先拿到锁并写完,再发生 register,则这次写入发生在抢占之前,可以接受。 diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 53506ed7..7aeddedc 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -259,14 +259,14 @@ sequenceDiagram 1. 创建状态为 `initializing` 的 Code Session; 2. 写入 sequence 1 的 `initialize` inbound; -3. 调用 `CommitManagedAgentCodeSessionActivation`,在最终激活事务内读取 queue 与完整 +3. 调用 `ActivateManagedAgentCodeSession`,在最终激活事务内读取 queue 与完整 `session_events`,按稳定顺序构造 inbound 并激活; 4. 激活成功后才继续签发并返回 runtime 启动信息; 5. 中途失败时,现有 defer cleanup 将未完成的 Code Session terminate。 ### 一个事务完成读取、交接与激活 -`Service.CommitManagedAgentCodeSessionActivation` 通过 +`Service.ActivateManagedAgentCodeSession` 通过 `DB.WithManagedAgentActivationTx` 定义事务边界并固定执行以下顺序;DB 的事务对象只暴露 Session、queue 和 Code Session 各自的 SQL 操作,不编排跨资源业务流程: @@ -403,7 +403,7 @@ Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态 | queue 事务内加载及 ownership / type 校验(不决定 inbound 序) | `ManagedAgentActivationTx.ListSessionEventQueueItems` | | 激活历史的稳定顺序读取 | `ManagedAgentActivationTx.ListSessionEventsForActivation` | | 激活 inbound 分块批量写入 | `ManagedAgentActivationTx.AppendCodeSessionInboundEvents` | -| queue、history、inbound 与 active 原子交接 | `Service.CommitManagedAgentCodeSessionActivation`、`DB.WithManagedAgentActivationTx` | +| queue、history、inbound 与 active 原子交接 | `Service.ActivateManagedAgentCodeSession`、`DB.WithManagedAgentActivationTx` | | Deployment initial events 入队 | `DB.CreateManualDeploymentRun` | | active 当前 batch 投递 | `Service.QueuePublicSessionEvents` | diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index b698b5f9..28f94751 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -98,7 +98,7 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag if err := s.queueInitialize(ctx, record, input.Config, now); err != nil { return ManagedAgentCreateResult{}, err } - if err := s.CommitManagedAgentCodeSessionActivation(ctx, record); err != nil { + if err := s.ActivateManagedAgentCodeSession(ctx, record); err != nil { return ManagedAgentCreateResult{}, err } credentialContext, err := s.db.GetCodeSessionCredentialContextForIssue( @@ -125,10 +125,10 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag }, nil } -// CommitManagedAgentCodeSessionActivation locks the owning Session, loads the +// ActivateManagedAgentCodeSession locks the owning Session, loads the // startup queue and complete public history, writes forwardable events in // stable order, clears the queue, and activates the Code Session atomically. -func (s *Service) CommitManagedAgentCodeSessionActivation( +func (s *Service) ActivateManagedAgentCodeSession( ctx context.Context, codeSession db.CodeSession, ) error { diff --git a/internal/db/code_session_inbound_event_mapper.go b/internal/db/code_session_inbound_event_mapper.go index 734d69d8..301f6319 100644 --- a/internal/db/code_session_inbound_event_mapper.go +++ b/internal/db/code_session_inbound_event_mapper.go @@ -11,6 +11,17 @@ import ( // CodeSessionInboundEventMapper contains queries whose primary table is // code_session_inbound_events. type CodeSessionInboundEventMapper interface { + GetCodeSessionInboundEventByIdempotencyKey( + ctx context.Context, + workspaceUUID uuid.UUID, + idempotencyKey string, + ) (codeSessionEventRow, bool, error) + + InsertCodeSessionInboundEvent( + ctx context.Context, + row codeSessionInboundEventInsertRow, + ) (codeSessionEventRow, error) + ListExistingActivationInboundEvents( ctx context.Context, organizationUUID uuid.UUID, diff --git a/internal/db/code_session_inbound_event_mapper.xml b/internal/db/code_session_inbound_event_mapper.xml index e0aa62ff..0317f9da 100644 --- a/internal/db/code_session_inbound_event_mapper.xml +++ b/internal/db/code_session_inbound_event_mapper.xml @@ -4,6 +4,93 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + uuid, + external_id, + organization_uuid, + workspace_uuid, + code_session_uuid, + code_session_external_id, + sequence_num, + event_type, + event_subtype, + payload_uuid, + request_id, + payload, + payload_hash, + idempotency_key, + delivery_status, + source, + sent_at, + delivery_worker_epoch, + received_at, + processing_at, + processed_at, + last_delivery_attempt_at, + last_delivery_update_at, + delivery_attempts, + false AS ephemeral, + created_at, + updated_at, + deleted_at + + + + + + INSERT INTO code_session_inbound_events ( + external_id, + organization_uuid, + workspace_uuid, + code_session_uuid, + code_session_external_id, + sequence_num, + event_type, + event_subtype, + payload_uuid, + request_id, + payload, + payload_hash, + idempotency_key, + delivery_status, + source, + created_at, + updated_at + ) VALUES ( + #{row.ExternalID}, + #{row.OrganizationUUID}, + #{row.WorkspaceUUID}, + #{row.CodeSessionUUID}, + #{row.CodeSessionExternalID}, + #{row.SequenceNum}, + #{row.EventType}, + #{row.EventSubtype}, + #{row.PayloadUUID}, + #{row.RequestID}, + CAST(#{row.Payload} AS jsonb), + #{row.PayloadHash}, + #{row.IdempotencyKey}, + #{row.DeliveryStatus}, + #{row.Source}, + #{row.CreatedAt}, + #{row.CreatedAt} + ) + RETURNING + + + + SELECT + + FROM code_sessions + WHERE external_id = #{codeSessionExternalID} + AND deleted_at IS NULL + FOR UPDATE + + + SELECT + + FROM code_session_outbound_events + WHERE workspace_uuid = #{workspaceUUID} + AND idempotency_key = #{idempotencyKey} + AND deleted_at IS NULL + LIMIT 1 + + + + INSERT INTO code_session_outbound_events ( + external_id, + organization_uuid, + workspace_uuid, + code_session_uuid, + code_session_external_id, + sequence_num, + event_type, + event_subtype, + payload_uuid, + request_id, + payload, + payload_hash, + idempotency_key, + source, + ephemeral, + created_at, + updated_at + ) VALUES ( + #{row.ExternalID}, + #{row.OrganizationUUID}, + #{row.WorkspaceUUID}, + #{row.CodeSessionUUID}, + #{row.CodeSessionExternalID}, + #{row.SequenceNum}, + #{row.EventType}, + #{row.EventSubtype}, + #{row.PayloadUUID}, + #{row.RequestID}, + CAST(#{row.Payload} AS jsonb), + #{row.PayloadHash}, + #{row.IdempotencyKey}, + #{row.Source}, + #{row.Ephemeral}, + #{row.CreatedAt}, + #{row.CreatedAt} + ) + RETURNING + + + diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 4e401e17..e3d2b789 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/jmoiron/sqlx" "github.com/samber/lo" + "github.com/superduck-ai/yourbatis" ) type CodeSession struct { @@ -1015,152 +1015,122 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS if input.RequiredWorkerEpoch != nil && *input.RequiredWorkerEpoch <= 0 { return CodeSessionEvent{}, false, ErrWorkerEpochMismatch } - tx, err := d.sql.BeginTxx(ctx, nil) - if err != nil { - return CodeSessionEvent{}, false, err - } - defer tx.Rollback() - - session, err := getCodeSessionSQLX(ctx, tx, ` - select `+codeSessionColumns()+` - from code_sessions - where external_id = :external_id and deleted_at is null - for update - `, map[string]any{"external_id": codeSessionExternalID}) - if err != nil { - return CodeSessionEvent{}, false, err - } - event, duplicate, err := d.appendCodeSessionEventSQLXTx(ctx, tx, session, direction, input) - if err != nil { - return CodeSessionEvent{}, false, err - } - if err := tx.Commit(); err != nil { - return CodeSessionEvent{}, false, err - } - return event, duplicate, nil -} + var event CodeSessionEvent + var duplicate bool + err := d.mapperDB.Transaction(ctx, func(executor yourbatis.Executor) error { + codeSessionMapper := NewCodeSessionMapper(executor) + inboundMapper := NewCodeSessionInboundEventMapper(executor) + outboundMapper := NewCodeSessionOutboundEventMapper(executor) -func (d *DB) appendCodeSessionEventSQLXTx( - ctx context.Context, - tx *sqlx.Tx, - session CodeSession, - direction string, - input AppendCodeSessionEventInput, -) (CodeSessionEvent, bool, error) { - if input.RequiredWorkerEpoch != nil && session.CurrentWorkerEpoch != *input.RequiredWorkerEpoch { - return CodeSessionEvent{}, false, ErrWorkerEpochMismatch - } - if input.IdempotencyKey != "" { - existing, err := d.getCodeSessionEventTx(ctx, tx, direction, session.WorkspaceUUID, input.IdempotencyKey) - if err == nil { - return existing, true, nil + session, found, err := codeSessionMapper.LockCodeSessionByExternalID(ctx, codeSessionExternalID) + if err != nil { + return err } - if !errors.Is(err, ErrNotFound) { - return CodeSessionEvent{}, false, err + if !found { + return ErrNotFound + } + if input.RequiredWorkerEpoch != nil && session.CurrentWorkerEpoch != *input.RequiredWorkerEpoch { + return ErrWorkerEpochMismatch + } + if input.IdempotencyKey != "" { + var existing codeSessionEventRow + if direction == "outbound" { + existing, found, err = outboundMapper.GetCodeSessionOutboundEventByIdempotencyKey( + ctx, session.WorkspaceUUID, input.IdempotencyKey, + ) + } else { + existing, found, err = inboundMapper.GetCodeSessionInboundEventByIdempotencyKey( + ctx, session.WorkspaceUUID, input.IdempotencyKey, + ) + } + if err != nil { + return err + } + if found { + event = existing.event() + duplicate = true + return nil + } } - } - now := input.CreatedAt - if now.IsZero() { - now = time.Now().UTC() - } - sequence := session.LastInboundSequenceNum + 1 - sequenceColumn := "last_inbound_sequence_num" - if direction == "outbound" { - sequence = session.LastOutboundSequenceNum + 1 - sequenceColumn = "last_outbound_sequence_num" - } - deliveryStatus := input.DeliveryStatus - if deliveryStatus == "" && direction == "inbound" { - deliveryStatus = "queued" - } + now := input.CreatedAt + if now.IsZero() { + now = time.Now().UTC() + } + if direction == "outbound" { + sequence := session.LastOutboundSequenceNum + 1 + inserted, err := outboundMapper.InsertCodeSessionOutboundEvent(ctx, codeSessionOutboundEventInsertRow{ + ExternalID: input.ExternalID, + OrganizationUUID: session.OrganizationUUID, + WorkspaceUUID: session.WorkspaceUUID, + CodeSessionUUID: session.UUID, + CodeSessionExternalID: session.ExternalID, + SequenceNum: sequence, + EventType: input.EventType, + EventSubtype: input.EventSubtype, + PayloadUUID: input.PayloadUUID, + RequestID: input.RequestID, + Payload: input.Payload, + PayloadHash: input.PayloadHash, + IdempotencyKey: input.IdempotencyKey, + Source: input.Source, + Ephemeral: input.Ephemeral, + CreatedAt: now, + }) + if err != nil { + return err + } + updated, err := codeSessionMapper.UpdateCodeSessionOutboundSequence(ctx, session.UUID, sequence, now) + if err != nil { + return err + } + if updated != 1 { + return ErrInvalidState + } + event = inserted.event() + return nil + } - var ( - event CodeSessionEvent - err error - ) - eventArguments := map[string]any{ - "external_id": input.ExternalID, - "organization_uuid": dbUUID(session.OrganizationUUID), - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "code_session_uuid": dbUUID(session.UUID), - "code_session_external_id": session.ExternalID, - "sequence_num": sequence, - "event_type": input.EventType, - "event_subtype": input.EventSubtype, - "payload_uuid": input.PayloadUUID, - "request_id": input.RequestID, - "payload": jsonArg(input.Payload), - "payload_hash": input.PayloadHash, - "idempotency_key": input.IdempotencyKey, - "source": input.Source, - "created_at": now, - } - if direction == "inbound" { - eventArguments["delivery_status"] = deliveryStatus - event, err = getCodeSessionEventSQLX(ctx, tx, ` - insert into code_session_inbound_events ( - external_id, organization_uuid, workspace_uuid, code_session_uuid, code_session_external_id, - sequence_num, event_type, event_subtype, payload_uuid, request_id, payload, - payload_hash, idempotency_key, delivery_status, source, created_at, updated_at - ) - values ( - :external_id, :organization_uuid, :workspace_uuid, :code_session_uuid, - :code_session_external_id, :sequence_num, :event_type, :event_subtype, - :payload_uuid, :request_id, CAST(:payload AS jsonb), :payload_hash, - :idempotency_key, :delivery_status, :source, :created_at, :created_at - ) - returning `+codeSessionInboundEventColumns()+` - `, eventArguments) - } else { - eventArguments["ephemeral"] = input.Ephemeral - event, err = getCodeSessionEventSQLX(ctx, tx, ` - insert into code_session_outbound_events ( - external_id, organization_uuid, workspace_uuid, code_session_uuid, code_session_external_id, - sequence_num, event_type, event_subtype, payload_uuid, request_id, payload, - payload_hash, idempotency_key, source, ephemeral, created_at, updated_at - ) - values ( - :external_id, :organization_uuid, :workspace_uuid, :code_session_uuid, - :code_session_external_id, :sequence_num, :event_type, :event_subtype, - :payload_uuid, :request_id, CAST(:payload AS jsonb), :payload_hash, - :idempotency_key, :source, :ephemeral, :created_at, :created_at - ) - returning `+codeSessionOutboundEventColumns()+` - `, eventArguments) - } + deliveryStatus := input.DeliveryStatus + if deliveryStatus == "" { + deliveryStatus = "queued" + } + sequence := session.LastInboundSequenceNum + 1 + inserted, err := inboundMapper.InsertCodeSessionInboundEvent(ctx, codeSessionInboundEventInsertRow{ + ExternalID: input.ExternalID, + OrganizationUUID: session.OrganizationUUID, + WorkspaceUUID: session.WorkspaceUUID, + CodeSessionUUID: session.UUID, + CodeSessionExternalID: session.ExternalID, + SequenceNum: sequence, + EventType: input.EventType, + EventSubtype: input.EventSubtype, + PayloadUUID: input.PayloadUUID, + RequestID: input.RequestID, + Payload: input.Payload, + PayloadHash: input.PayloadHash, + IdempotencyKey: input.IdempotencyKey, + DeliveryStatus: deliveryStatus, + Source: input.Source, + CreatedAt: now, + }) + if err != nil { + return err + } + updated, err := codeSessionMapper.UpdateCodeSessionInboundSequence(ctx, session.UUID, sequence, now) + if err != nil { + return err + } + if updated != 1 { + return ErrInvalidState + } + event = inserted.event() + return nil + }) if err != nil { return CodeSessionEvent{}, false, err } - if _, err := namedExecContext(ctx, tx, `update code_sessions set `+sequenceColumn+` = :sequence_num, updated_at = :now where uuid = :uuid`, map[string]any{ - "sequence_num": sequence, - "now": now, - "uuid": dbUUID(session.UUID), - }); err != nil { - return CodeSessionEvent{}, false, err - } - return event, false, nil -} - -func (d *DB) getCodeSessionEventTx(ctx context.Context, tx sqlxNamedQueryer, direction string, workspaceUUID string, idempotencyKey string) (CodeSessionEvent, error) { - arguments := map[string]any{ - "workspace_uuid": dbUUID(workspaceUUID), - "idempotency_key": idempotencyKey, - } - if direction == "outbound" { - return getCodeSessionEventSQLX(ctx, tx, ` - select `+codeSessionOutboundEventColumns()+` - from code_session_outbound_events - where workspace_uuid = :workspace_uuid and idempotency_key = :idempotency_key and deleted_at is null - limit 1 - `, arguments) - } - return getCodeSessionEventSQLX(ctx, tx, ` - select `+codeSessionInboundEventColumns()+` - from code_session_inbound_events - where workspace_uuid = :workspace_uuid and idempotency_key = :idempotency_key and deleted_at is null - limit 1 - `, arguments) + return event, duplicate, nil } func (d *DB) ListCodeSessionInternalEventsPage(ctx context.Context, params ListCodeSessionInternalEventsPageParams) ([]CodeSessionInternalEvent, bool, error) { diff --git a/internal/db/code_sessions_sqlx.go b/internal/db/code_sessions_sqlx.go index e0125683..aa7abf32 100644 --- a/internal/db/code_sessions_sqlx.go +++ b/internal/db/code_sessions_sqlx.go @@ -99,6 +99,25 @@ type codeSessionInboundEventInsertRow struct { CreatedAt time.Time `db:"created_at"` } +type codeSessionOutboundEventInsertRow struct { + ExternalID string `db:"external_id"` + OrganizationUUID uuid.UUID `db:"organization_uuid"` + WorkspaceUUID uuid.UUID `db:"workspace_uuid"` + CodeSessionUUID uuid.UUID `db:"code_session_uuid"` + CodeSessionExternalID string `db:"code_session_external_id"` + SequenceNum int64 `db:"sequence_num"` + EventType string `db:"event_type"` + EventSubtype string `db:"event_subtype"` + PayloadUUID *string `db:"payload_uuid"` + RequestID *string `db:"request_id"` + Payload []byte `db:"payload"` + PayloadHash string `db:"payload_hash"` + IdempotencyKey string `db:"idempotency_key"` + Source string `db:"source"` + Ephemeral bool `db:"ephemeral"` + CreatedAt time.Time `db:"created_at"` +} + type codeSessionInternalEventRow struct { UUID uuid.UUID `db:"uuid"` ExternalID string `db:"external_id"` diff --git a/internal/db/yourbatis_mappers_test.go b/internal/db/yourbatis_mappers_test.go index e5f5439a..d97ad969 100644 --- a/internal/db/yourbatis_mappers_test.go +++ b/internal/db/yourbatis_mappers_test.go @@ -24,6 +24,31 @@ func TestTableMappersBuildDynamicQueries(t *testing.T) { eventUUIDOne := uuid.MustParse("44444444-4444-4444-8444-444444444444") eventUUIDTwo := uuid.MustParse("55555555-5555-4555-8555-555555555555") + t.Run("single code session event append", func(t *testing.T) { + lockBound := buildCodeSessionMapperLockCodeSessionByExternalID( + yourbatis.DialectPostgres, + "cse_test", + ) + assertMapperSQLContains(t, lockBound, "WHERE external_id = $1 AND deleted_at IS NULL FOR UPDATE") + if strings.Contains(lockBound.SQL, "status = 'initializing'") { + t.Fatalf("daily append lock unexpectedly restricts status: %s", lockBound.SQL) + } + + inboundBound := buildCodeSessionInboundEventMapperGetCodeSessionInboundEventByIdempotencyKey( + yourbatis.DialectPostgres, + workspaceUUID, + "idem-inbound", + ) + assertMapperSQLContains(t, inboundBound, "workspace_uuid = $1 AND idempotency_key = $2 AND deleted_at IS NULL") + + outboundBound := buildCodeSessionOutboundEventMapperGetCodeSessionOutboundEventByIdempotencyKey( + yourbatis.DialectPostgres, + workspaceUUID, + "idem-outbound", + ) + assertMapperSQLContains(t, outboundBound, "workspace_uuid = $1 AND idempotency_key = $2 AND deleted_at IS NULL") + }) + t.Run("idempotency keys", func(t *testing.T) { bound := buildCodeSessionInboundEventMapperListExistingActivationInboundEvents( yourbatis.DialectPostgres, @@ -140,6 +165,36 @@ func TestTableMappersBuildWrites(t *testing.T) { t.Fatalf("inbound batch argument count = %d, want 34", len(bound.Args)) } + singleInboundBound := buildCodeSessionInboundEventMapperInsertCodeSessionInboundEvent( + yourbatis.DialectPostgres, + row, + ) + assertMapperSQLContains(t, singleInboundBound, "INSERT INTO code_session_inbound_events") + assertMapperSQLContains(t, singleInboundBound, "RETURNING uuid, external_id") + + outboundRow := codeSessionOutboundEventInsertRow{ + ExternalID: "evt_outbound_test", + OrganizationUUID: organizationUUID, + WorkspaceUUID: workspaceUUID, + CodeSessionUUID: codeSessionUUID, + CodeSessionExternalID: "codeses_test", + SequenceNum: 1, + EventType: "assistant", + EventSubtype: "message", + Payload: []byte(`{"type":"assistant"}`), + PayloadHash: "hash-outbound", + IdempotencyKey: "idem-outbound", + Source: "worker", + Ephemeral: true, + CreatedAt: createdAt, + } + singleOutboundBound := buildCodeSessionOutboundEventMapperInsertCodeSessionOutboundEvent( + yourbatis.DialectPostgres, + outboundRow, + ) + assertMapperSQLContains(t, singleOutboundBound, "INSERT INTO code_session_outbound_events") + assertMapperSQLContains(t, singleOutboundBound, "RETURNING uuid, external_id") + outcomeBound := buildSessionMapperSetSessionOutcomeEvaluations( yourbatis.DialectPostgres, workspaceUUID, diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index f639fab5..4647a464 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -718,7 +718,7 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) } acceptedEventID := sessionEventStringField(t, accepted.Data[0], "id") - if err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession); err != nil { + if err := codeSessionService.ActivateManagedAgentCodeSession(ctx, codeSession); err != nil { t.Fatalf("activate with queued session event: %v", err) } codeSession, err = app.db.GetCodeSession(ctx, codeSessionID) @@ -808,7 +808,7 @@ func TestManagedAgentActivationPreservesLargeHistoryOrder(t *testing.T) { if _, err := app.db.AppendSessionEvents(ctx, session.WorkspaceUUID, session.ExternalID, events); err != nil { t.Fatalf("append large history: %v", err) } - if err := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil).CommitManagedAgentCodeSessionActivation(ctx, codeSession); err != nil { + if err := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil).ActivateManagedAgentCodeSession(ctx, codeSession); err != nil { t.Fatalf("activate Code Session: %v", err) } @@ -871,7 +871,7 @@ func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing if err != nil || len(before) != 1 { t.Fatalf("rollback inbound before delivery = (%#v, %v), want initialize", before, err) } - if err := codeSessionService.CommitManagedAgentCodeSessionActivation(ctx, codeSession); err == nil { + if err := codeSessionService.ActivateManagedAgentCodeSession(ctx, codeSession); err == nil { t.Fatal("activation with invalid forwardable history succeeded") } if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 1 { @@ -2605,6 +2605,78 @@ func TestCodeSessionWorkerEventAppendChecksEpochInsideTransaction(t *testing.T) } } +func TestCodeSessionEventAppendPreservesIdempotencyAndDirectionSequences(t *testing.T) { + app := newTestAppWithStore(t, nil, newFakeStore("sessions-code-event-append-sequences-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-code-event-append-sequences-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-code-event-append-sequences-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + codeSessionID := launchLocalCodeSession(t, app, session.ID) + ctx := context.Background() + + before, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("load Code Session before append: %v", err) + } + suffix := strings.TrimPrefix(codeSessionID, "cse_") + inboundInput := db.AppendCodeSessionEventInput{ + ExternalID: "csev_inbound_sequence_" + suffix, + EventType: "user", + Payload: json.RawMessage(`{"type":"user"}`), + PayloadHash: "inbound-sequence", + IdempotencyKey: "inbound-sequence:" + suffix, + Source: "test", + } + inbound, duplicate, err := app.db.AppendCodeSessionInboundEvent(ctx, codeSessionID, inboundInput) + if err != nil || duplicate { + t.Fatalf("append inbound event = (%+v, duplicate=%v, err=%v)", inbound, duplicate, err) + } + if inbound.SequenceNum != before.LastInboundSequenceNum+1 { + t.Fatalf("inbound sequence = %d, want %d", inbound.SequenceNum, before.LastInboundSequenceNum+1) + } + duplicateInbound, duplicate, err := app.db.AppendCodeSessionInboundEvent(ctx, codeSessionID, inboundInput) + if err != nil || !duplicate || duplicateInbound.UUID != inbound.UUID { + t.Fatalf("duplicate inbound event = (%+v, duplicate=%v, err=%v), want UUID %q", duplicateInbound, duplicate, err, inbound.UUID) + } + + outboundInput := db.AppendCodeSessionEventInput{ + ExternalID: "csev_outbound_sequence_" + suffix, + EventType: "assistant", + Payload: json.RawMessage(`{"type":"assistant"}`), + PayloadHash: "outbound-sequence", + IdempotencyKey: "outbound-sequence:" + suffix, + Source: "test", + } + outbound, duplicate, err := app.db.AppendCodeSessionOutboundEvent(ctx, codeSessionID, outboundInput) + if err != nil || duplicate { + t.Fatalf("append outbound event = (%+v, duplicate=%v, err=%v)", outbound, duplicate, err) + } + if outbound.SequenceNum != before.LastOutboundSequenceNum+1 { + t.Fatalf("outbound sequence = %d, want %d", outbound.SequenceNum, before.LastOutboundSequenceNum+1) + } + duplicateOutbound, duplicate, err := app.db.AppendCodeSessionOutboundEvent(ctx, codeSessionID, outboundInput) + if err != nil || !duplicate || duplicateOutbound.UUID != outbound.UUID { + t.Fatalf("duplicate outbound event = (%+v, duplicate=%v, err=%v), want UUID %q", duplicateOutbound, duplicate, err, outbound.UUID) + } + + after, err := app.db.GetCodeSession(ctx, codeSessionID) + if err != nil { + t.Fatalf("load Code Session after append: %v", err) + } + if after.LastInboundSequenceNum != inbound.SequenceNum || after.LastOutboundSequenceNum != outbound.SequenceNum { + t.Fatalf( + "stored sequences = inbound %d/outbound %d, want inbound %d/outbound %d", + after.LastInboundSequenceNum, + after.LastOutboundSequenceNum, + inbound.SequenceNum, + outbound.SequenceNum, + ) + } +} + func TestCodeSessionWorkerConnectionStateUpdatesAreEpochScoped(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("sessions-code-worker-epoch-state-bucket")) defer app.close() From d3e211a5c331f7ecb8bba6363baba169e32faf6d Mon Sep 17 00:00:00 2001 From: xgxgx Date: Tue, 4 Aug 2026 00:01:51 +0800 Subject: [PATCH 20/23] fix(ci): generate yourbatis mappers before Go typecheck CI and local quality gates failed on a clean checkout because *.sqlmap.gen.go is gitignored. Run scripts/generate-go.sh before lint, dead-code, complexity, tests, server restart, and Docker build. --- .github/workflows/complexity.yml | 7 +++++++ .github/workflows/dead-code.yml | 7 +++++++ .github/workflows/lint.yml | 3 +++ .gitignore | 2 +- .pre-commit-config.yaml | 8 ++++---- Dockerfile | 3 ++- .../be/session-startup-message-delivery.md | 5 +++-- docs/design/development-quality-gates.md | 3 ++- justfile | 7 +++++-- scripts/generate-go.sh | 7 +++++++ scripts/go-complexity.sh | 2 ++ scripts/go-dead-code.sh | 2 ++ scripts/pre-commit-go-lint.sh | 18 +++++++++++++++--- scripts/restart-server.sh | 1 + 14 files changed, 61 insertions(+), 14 deletions(-) create mode 100755 scripts/generate-go.sh diff --git a/.github/workflows/complexity.yml b/.github/workflows/complexity.yml index 5b93b2be..75ae2d0a 100644 --- a/.github/workflows/complexity.yml +++ b/.github/workflows/complexity.yml @@ -4,9 +4,11 @@ on: pull_request: paths: - '**/*.go' + - 'internal/db/*.xml' - '.golangci-complexity.yml' - 'go.mod' - 'go.sum' + - 'scripts/generate-go.sh' - 'scripts/go-complexity.sh' - 'web/**' - '.github/workflows/complexity.yml' @@ -15,9 +17,11 @@ on: - main paths: - '**/*.go' + - 'internal/db/*.xml' - '.golangci-complexity.yml' - 'go.mod' - 'go.sum' + - 'scripts/generate-go.sh' - 'scripts/go-complexity.sh' - 'web/**' - '.github/workflows/complexity.yml' @@ -44,6 +48,9 @@ jobs: go-version-file: go.mod cache: true + - name: Generate Go sources + run: ./scripts/generate-go.sh + - name: Check Go complexity uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/dead-code.yml b/.github/workflows/dead-code.yml index 15430fba..0c039f03 100644 --- a/.github/workflows/dead-code.yml +++ b/.github/workflows/dead-code.yml @@ -4,9 +4,11 @@ on: pull_request: paths: - '**/*.go' + - 'internal/db/*.xml' - '.golangci-dead-code.yml' - 'go.mod' - 'go.sum' + - 'scripts/generate-go.sh' - 'scripts/go-dead-code.sh' - '.github/workflows/dead-code.yml' push: @@ -14,9 +16,11 @@ on: - main paths: - '**/*.go' + - 'internal/db/*.xml' - '.golangci-dead-code.yml' - 'go.mod' - 'go.sum' + - 'scripts/generate-go.sh' - 'scripts/go-dead-code.sh' - '.github/workflows/dead-code.yml' @@ -42,6 +46,9 @@ jobs: go-version-file: go.mod cache: true + - name: Generate Go sources + run: ./scripts/generate-go.sh + - name: Detect unreachable Go declarations uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fd7509ce..c80a1b9b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,6 +28,9 @@ jobs: go-version-file: go.mod cache: true + - name: Generate Go sources + run: ./scripts/generate-go.sh + - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.gitignore b/.gitignore index 43c8de9b..dc22e2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,4 @@ logs/ CLAUDE.local.md AGENTS.override.md .worktrees/ -*.gen.go \ No newline at end of file +*.gen.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7fe750b3..0fa0fcbb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,17 +26,17 @@ repos: types: [go] - id: golangci-lint - name: Lint packages containing staged Go files + name: Lint packages containing staged Go inputs entry: scripts/pre-commit-go-lint.sh language: script - types: [go] + files: ^(?:.*\.go|internal/db/.*\.xml|go\.(?:mod|sum)|scripts/generate-go\.sh)$ require_serial: true - id: go-dead-code name: Detect unreachable Go declarations entry: scripts/go-dead-code.sh language: script - files: ^(?:.*\.go|\.golangci-dead-code\.yml|go\.(?:mod|sum)|scripts/go-dead-code\.sh)$ + files: ^(?:.*\.go|internal/db/.*\.xml|\.golangci-dead-code\.yml|go\.(?:mod|sum)|scripts/(?:generate-go|go-dead-code)\.sh)$ pass_filenames: false require_serial: true @@ -44,7 +44,7 @@ repos: name: Enforce Go cyclomatic complexity entry: scripts/go-complexity.sh language: script - files: ^(?:.*\.go|\.golangci-complexity\.yml|go\.(?:mod|sum)|scripts/go-complexity\.sh)$ + files: ^(?:.*\.go|internal/db/.*\.xml|\.golangci-complexity\.yml|go\.(?:mod|sum)|scripts/(?:generate-go|go-complexity)\.sh)$ pass_filenames: false require_serial: true diff --git a/Dockerfile b/Dockerfile index d8f89c29..685c3363 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,8 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /oma-server . +RUN go generate ./internal/db \ + && CGO_ENABLED=0 go build -ldflags="-s -w" -o /oma-server . # ---- 前端构建 (Bun) --------------------------------------------------------- FROM ${REGISTRY}/node:22 AS web-builder diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 7aeddedc..2c09b76f 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -206,8 +206,9 @@ DB 组装层从 sqlx 使用的同一个 `*sql.DB` 创建共享 `yourbatis.DB`, Send 和最终激活通过 `yourbatis.DB.Transaction` 开启事务,并用回调提供的事务 `Executor` 构造 Mapper;不再把 `sqlx.Tx` 包装成自定义 yourbatis Executor。Deployment 创建和 Session 删除 仍包含尚未迁移的 sqlx 事务链,其 queue 操作继续使用同一个 sqlx transaction,避免一次业务事务 -跨两个句柄。仓库提交生成文件;yourbatis 版本由 `go.mod` 固定,需要重新生成时运行 -`go generate ./internal/db`。 +跨两个句柄。仓库不提交生成文件;yourbatis 版本由 `go.mod` 固定,`scripts/generate-go.sh` +统一运行 `go generate ./internal/db`,并由本地门禁、CI、开发启动和 Docker 构建在使用 Go package +之前调用。 ```mermaid sequenceDiagram diff --git a/docs/design/development-quality-gates.md b/docs/design/development-quality-gates.md index 69f1fbb5..d765f34d 100644 --- a/docs/design/development-quality-gates.md +++ b/docs/design/development-quality-gates.md @@ -15,6 +15,7 @@ ## 配置与执行 +- yourbatis 根据 `internal/db/*.xml` 生成的 `*.sqlmap.gen.go` 不提交;`scripts/generate-go.sh` 是统一生成入口。Go lint、死代码、复杂度、测试、开发启动和 Docker 构建在类型检查或编译前调用该入口,避免本地残留的 ignored 文件掩盖干净 checkout 中的缺失生成代码。 - `.golangci.yml` 是常规 Go lint 规则来源;复杂度和死代码等需要不同扫描范围的专项门禁使用独立的固定配置。 - `just lint` 在本地对所有 Go package(包括测试)运行相同配置。 - `.golangci-dead-code.yml` 单独启用 golangci-lint 的 `unused` 分析器并覆盖测试代码;`just dead-code` 通过 `scripts/go-dead-code.sh` 枚举当前 Go module 的仓库 package,避免本地前端依赖中的第三方 Go 示例污染结果。 @@ -36,5 +37,5 @@ just large-files just lint just dead-code just duplicates -go test ./... -count=1 +just test ``` diff --git a/justfile b/justfile index 52eae705..c7812b45 100644 --- a/justfile +++ b/justfile @@ -36,11 +36,14 @@ restart-web: # Restart weather MCP server in foreground. Override with: PORT=39091 WEATHER_MCP_PATH=/custom just weather-mcp -test: +generate-go: + ./scripts/generate-go.sh + +test: generate-go go test ./... -count=1 # Run the repository's configured Go static-analysis and formatting checks. -lint: +lint: generate-go golangci-lint run --config .golangci.yml ./... dead-code: diff --git a/scripts/generate-go.sh b/scripts/generate-go.sh new file mode 100755 index 00000000..096edaac --- /dev/null +++ b/scripts/generate-go.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +go generate ./internal/db diff --git a/scripts/go-complexity.sh b/scripts/go-complexity.sh index 2ad95178..9f9d7c97 100755 --- a/scripts/go-complexity.sh +++ b/scripts/go-complexity.sh @@ -9,6 +9,8 @@ if ! command -v golangci-lint >/dev/null 2>&1; then exit 1 fi +./scripts/generate-go.sh + packages=() while IFS= read -r directory; do [[ "$directory" == "$repo_root/web/node_modules/"* ]] && continue diff --git a/scripts/go-dead-code.sh b/scripts/go-dead-code.sh index 17fcb253..4fb66773 100755 --- a/scripts/go-dead-code.sh +++ b/scripts/go-dead-code.sh @@ -9,6 +9,8 @@ if ! command -v golangci-lint >/dev/null 2>&1; then exit 1 fi +./scripts/generate-go.sh + packages=() while IFS= read -r directory; do [[ "$directory" == "$repo_root/web/node_modules/"* ]] && continue diff --git a/scripts/pre-commit-go-lint.sh b/scripts/pre-commit-go-lint.sh index a56f6c3f..ab2ee23b 100755 --- a/scripts/pre-commit-go-lint.sh +++ b/scripts/pre-commit-go-lint.sh @@ -1,18 +1,30 @@ #!/usr/bin/env bash set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + if ! command -v golangci-lint >/dev/null 2>&1; then echo 'golangci-lint is required for staged Go files. Install the version used by .github/workflows/lint.yml.' >&2 exit 1 fi +./scripts/generate-go.sh + packages=() for file in "$@"; do [[ -f "$file" ]] || continue - directory="$(dirname "$file")" - package="./${directory}" - [[ "$directory" == '.' ]] && package='.' + case "$file" in + go.mod|go.sum|scripts/generate-go.sh) + package='./...' + ;; + *) + directory="$(dirname "$file")" + package="./${directory}" + [[ "$directory" == '.' ]] && package='.' + ;; + esac seen=false for existing in "${packages[@]:-}"; do diff --git a/scripts/restart-server.sh b/scripts/restart-server.sh index 467e859b..e993f948 100755 --- a/scripts/restart-server.sh +++ b/scripts/restart-server.sh @@ -60,6 +60,7 @@ stop_listeners() { fi } +"$REPO_ROOT/scripts/generate-go.sh" stop_listeners echo "Starting claude-api-server with $CONFIG_FILE in foreground" From 8819d8624e634c064971e03abcae9716d2b0e956 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Tue, 4 Aug 2026 16:21:34 +0800 Subject: [PATCH 21/23] fix: reliably deliver startup session events --- .../be/session-startup-message-delivery.md | 452 ++--------------- .../managed_agent_code_session.go | 20 +- internal/db/db.go | 28 +- internal/db/deployments.go | 9 - internal/db/environment_work_mapper.go | 19 - internal/db/environment_work_mapper.xml | 19 - internal/db/managed_agent_activation.go | 23 +- .../00047_add_session_event_queue.sql | 19 - internal/db/session_event_mapper.go | 19 +- internal/db/session_event_mapper.xml | 49 +- internal/db/session_event_queue.go | 461 ------------------ internal/db/session_event_queue_mapper.go | 33 -- internal/db/session_event_queue_mapper.xml | 47 -- internal/db/session_event_queue_sqlx.go | 104 ---- internal/db/session_mapper.go | 11 +- internal/db/session_mapper.xml | 9 - internal/db/session_thread_mapper.go | 25 - internal/db/session_thread_mapper.xml | 47 -- internal/db/sessions.go | 20 +- internal/db/sessions_migration_sqlx_test.go | 8 - internal/db/sessions_sqlx.go | 6 +- internal/db/yourbatis_mappers_test.go | 147 ------ internal/sessions/code_event_bridge.go | 2 +- internal/sessions/service.go | 20 +- tests/deployments_api_test.go | 17 +- tests/environments_runner_cloud_test.go | 3 - tests/sessions_api_test.go | 293 +++-------- tests/uuid_boundary_postgres_test.go | 2 +- 28 files changed, 185 insertions(+), 1727 deletions(-) delete mode 100644 internal/db/environment_work_mapper.go delete mode 100644 internal/db/environment_work_mapper.xml delete mode 100644 internal/db/migrations/00047_add_session_event_queue.sql delete mode 100644 internal/db/session_event_queue.go delete mode 100644 internal/db/session_event_queue_mapper.go delete mode 100644 internal/db/session_event_queue_mapper.xml delete mode 100644 internal/db/session_event_queue_sqlx.go delete mode 100644 internal/db/session_thread_mapper.go delete mode 100644 internal/db/session_thread_mapper.xml diff --git a/docs/design/be/session-startup-message-delivery.md b/docs/design/be/session-startup-message-delivery.md index 2c09b76f..91997a42 100644 --- a/docs/design/be/session-startup-message-delivery.md +++ b/docs/design/be/session-startup-message-delivery.md @@ -1,430 +1,86 @@ # Session 启动期消息可靠投递 -## 一句话结论 +## 问题 -Session 对外仍然立即返回 `idle`;在 Code Session 可用前收到的第一条 -`user.message`,由 `session_events` 和 `session_event_queue` 在同一事务中接收,随后在 -Code Session 激活事务中与 queue 清理一起原子写入 inbound,保证 API 返回成功的消息不会 -丢失。 +Runner 过去在 prepare 阶段读取一次 `session_events` 快照,随后才创建 sandbox 和 Code +Session。快照之后、Code Session 创建之前发送的消息虽然已经写入 `session_events`,但不会 +进入 runtime 消费的 `code_session_inbound_events`。 -## 目标与边界 +## 设计 -### 目标 +`session_events` 是启动输入的唯一事实源,不增加临时 queue、watermark 或公开状态。 -修复 Issue #189:Runner prepare 之后、Code Session 创建或激活之前发送的消息,API 已经 -返回 200,但消息没有进入 Code Session inbound。 +Send Events 与 Code Session activation 都先锁同一条 Session 行: -必须保证: +- Send 通过 `DB.AppendSessionEvents` 锁 Session 并提交公开事件; +- activation 锁 Session 后读取完整公开历史,在同一事务中写 inbound 并将 Code Session 从 + `initializing` 切为 `active`。 -1. 启动窗口内成功接收的第一条 `user.message` 最终进入对应 Code Session; -2. 普通 Send Events 在整个启动窗口内最多成功接收一条 `user.message`; -3. queue 交接完成以前 Code Session 不得变成 `active`; -4. queue 交接失败时不删除责任记录,也不留下部分 inbound; -5. active 后保持原有“只实时投递当前 batch”的行为。 +因此只可能有两种顺序: -### 不改变的行为 - -- 不增加 `starting` 等公开 Session 状态,客户端仍看到现有 `idle`; -- 不阻止客户端在 Session 创建后调用 Send Events; -- 不在 Send Events 路径扫描或重放历史;历史注入仅发生在 Code Session 激活; -- 不维护跨激活的 watermark / 通用 outbox; -- 不在 worker register 或 heartbeat 中补投; -- 不把 queue 扩展成永久投递历史; -- 不改变 active 路径的事件转换、tool confirmation 和 batch 范围。 - -## 修改前后差异 - -| 关注点 | 修改前 | 修改后 | -| --- | --- | --- | -| 启动期增量来源 | Runner prepare 时读取一次含历史的 `InitialEvents` 快照 | Send 启动窗写入 queue;激活时再合并历史 `session_events` | -| prepare 后到达的消息 | 不在旧快照中 | 与 `session_events` 同事务进入 queue | -| Code Session 创建 | 使用 prepare 阶段快照写 inbound | 锁定 Session 后读取完整 queue 与历史并原子激活 | -| 消息责任 | API 200 后没有跨启动流程的持久化责任 | queue row 持有到最终激活事务提交 | -| 激活条件 | 快照处理完成后继续启动 | 锁内读取 history、写 inbound、清空 queue、切 `active` 一次提交 | -| active 后发送 | 实时投递当前 batch | 保持不变 | - -变化的核心不是“创建 Code Session 时扫描更多历史”,而是把投递责任前移到接收消息的事务: -只要启动期 Send Events 返回 200,数据库中就同时存在公开事件事实和一条尚未完成的交接 -责任。 - -## 修改前:消息为什么会丢 - -Runner 在 prepare 阶段读取事件快照。prepare 后还要执行资源准备、依赖安装和 sandbox -启动,此时 Code Session 尚不存在。窗口内的新消息只能写入 `session_events`:它不在旧快照 -中,也没有 inbound 目标。 - -```mermaid -sequenceDiagram - autonumber - actor Client - participant API as Sessions API - participant Events as session_events - participant Runner - participant CS as Code Session - participant Inbound as code_session_inbound_events - - Client->>API: Create Session - API-->>Client: 200, status=idle - Runner->>Events: prepare 时读取 InitialEvents - Events-->>Runner: snapshot - Note over Runner,CS: 准备资源和 sandbox,Code Session 尚不存在 - Client->>API: Send user.message - API->>Events: INSERT user.message - API-->>Client: 200 - Runner->>CS: 使用旧 snapshot 创建 Code Session - Runner->>Inbound: INSERT initialize + snapshot events - Note over Events,Inbound: 新 user.message 不在 snapshot,也没有进入 inbound -``` - -## 修改后:端到端主流程 - -```mermaid -sequenceDiagram - autonumber - actor Client - participant API as Sessions API - participant Session as sessions row - participant Events as session_events - participant Queue as session_event_queue - participant Runner - participant CS as code_sessions - participant Inbound as code_session_inbound_events - - Client->>API: Create Session - API-->>Client: 200, status=idle - Runner->>Runner: prepare config/resources(不读事件快照) - - Client->>API: Send one user.message - API->>Session: SELECT FOR UPDATE - API->>API: 判断仍在启动窗口且 queue 为空 - API->>Events: INSERT user.message - API->>Queue: INSERT session_event_uuid - API->>API: COMMIT event + queue - API-->>Client: 200 - - Runner->>CS: INSERT status=initializing - Runner->>Inbound: INSERT initialize (sequence=1) - rect rgb(238, 247, 255) - Note over Session,CS: 最终激活事务 - Runner->>Session: SELECT FOR UPDATE - Runner->>CS: SELECT initializing FOR UPDATE - Runner->>Queue: SELECT full queue FOR UPDATE - Runner->>Events: SELECT complete history ORDER BY created_at, id - loop stable history order - Runner->>Runner: 过滤并转换可转发事件 - end - Runner->>Inbound: 分块批量 INSERT inbound - Runner->>Queue: DELETE all rows - Runner->>CS: UPDATE initializing → active - Runner->>Runner: COMMIT - end - - Note over Client,Inbound: inbound 顺序为 initialize + startup user messages -``` - -## 数据模型与职责 - -| 数据 | 事实含义 | 生命周期 | -| --- | --- | --- | -| `session_events` | Session API 已接受的公开事件 | 按现有 Session 事件生命周期保留 | -| `session_event_queue` | 事件尚未完成启动期 Code Session 交接 | Send 事务创建,激活事务删除 | -| `code_session_inbound_events` | 某个具体 Code Session 可以消费的输入 | 按现有 inbound 生命周期保留 | - -`session_event_queue` 是临时责任表,不是 payload 副本。它只保存: - -- queue 自身顺序 `id`; -- `organization_uuid`; -- `workspace_uuid`; -- `session_uuid`; -- `session_event_uuid`; -- 创建时间。 - -表中没有 payload、delivery status、重试次数或 delivered history。事件内容始终从 -`session_events` 读取,避免形成第二份事件事实源。`session_event_uuid` 唯一,防止同一公开 -事件重复获得两条 queue 责任。organization、workspace、Session 和 event 均使用稳定 UUID -引用,避免租户迁移、部分导入或跨库合并时 identity 重映射导致 queue row 失去归属。 - -queue 行按 `session_uuid` 归属当前 Session;读取 event 时按 `session_event_uuid` -定位 `session_events`。queue 引用的事件不存在或无法归属时,创建流程直接失败,不得写 -inbound、删 queue 或激活 Code Session。写入时仍落 `organization_uuid` / -`workspace_uuid`,作为稳定租户字段,但不作为查询必要条件。 - -## 启动窗口判定 - -启动窗口是后端事务内的判断,不是新的公开状态。 - -`shouldQueueForStartup` 的规则是: - -1. 查询该 Session 最新且未删除的 Code Session(按 `session_uuid`); -2. 如果 Code Session 存在且状态不是 `initializing`,不进入 startup queue; -3. 如果 Code Session 不存在或仍为 `initializing`,检查对应 Environment Work; -4. work 必须落在当前 Session 的 `workspace_uuid` / `environment_uuid` 上,data 指向 - 当前 Session,且状态为 `queued`、`starting` 或 `active`; -5. **不按 Environment 类型过滤**(例如不区分 `cloud` / `self_hosted`)。是否入队只取决于 - Code Session 是否尚未 active,以及是否仍有指向该 Session 的在途 environment work; -6. work 已停止或最新 Code Session 是其他状态时,保持既有事件行为。 - -这个判断必须在 Send 事务锁住 Session 行之后执行,不能在 API 层提前查询。发送和激活只有 -使用同一条 Session 行作为串行化边界,才能关闭最后一次 queue 检查与 `active` 切换之间的 -竞态。 - -## 普通 Send Events - -### API 层:只做内存计算 - -`Handler.sendEventsRoute` 先调用 `normalizeInputEvent` 生成 `SessionEvent`。如果 batch 中有 -`user.define_outcome`,只更新内存里的 `normalizedSession.OutcomeEvaluations`,不在 -normalization 阶段写库。 - -随后一次性调用 `DB.AppendSessionEventsForDelivery`,传入: - -- 标准化后的事件 batch; -- 可选的最终 outcome evaluations。 - -这样被 409 拒绝的 batch 不会提前修改 outcome。 - -### DB 层:同一事务接收事实与责任 - -`AppendSessionEventsForDelivery`: - -1. `SELECT ... FOR UPDATE` 锁定 Session; -2. 拒绝 archived Session; -3. batch 包含 `user.message` 时判断启动窗口; -4. 启动窗口只允许 batch 恰好包含一条 `user.message`; -5. queue 已有任何 row 时返回 `ErrSessionStartupMessageConflict`; -6. 写入 `session_events`; -7. 启动窗口内再写入对应 queue row; -8. 有 outcome 变化时在同一事务更新; -9. commit 后返回 `startup_queued` 或 `realtime`。 - -Send 和最终激活路径的锁查询、窗口判断、queue 读写、历史读取、inbound 批量写入和状态更新, -按主表分别声明在 `SessionMapper`、`SessionThreadMapper`、`SessionEventMapper`、 -`SessionEventQueueMapper`、`CodeSessionMapper`、`CodeSessionInboundEventMapper` 和 -`EnvironmentWorkMapper` 的 MyBatis XML 中,并由 yourbatis 生成静态 Go builder 与 scanner。 -单个 Mapper 不混合多个表的查询;跨表事务由 DB 组装层使用同一个事务 `Executor` 构造所需 Mapper。 -DB 组装层从 sqlx 使用的同一个 `*sql.DB` 创建共享 `yourbatis.DB`,不会建立第二个连接池。 -Send 和最终激活通过 `yourbatis.DB.Transaction` 开启事务,并用回调提供的事务 `Executor` -构造 Mapper;不再把 `sqlx.Tx` 包装成自定义 yourbatis Executor。Deployment 创建和 Session 删除 -仍包含尚未迁移的 sqlx 事务链,其 queue 操作继续使用同一个 sqlx transaction,避免一次业务事务 -跨两个句柄。仓库不提交生成文件;yourbatis 版本由 `go.mod` 固定,`scripts/generate-go.sh` -统一运行 `go generate ./internal/db`,并由本地门禁、CI、开发启动和 Docker 构建在使用 Go package -之前调用。 +1. Send 先提交,activation 随后读取到该事件并写入 inbound; +2. activation 先提交,Send 随后由现有 active realtime 路径投递当前 batch。 ```mermaid sequenceDiagram - autonumber - actor Client - participant API as sendEventsRoute - participant DB as AppendSessionEventsForDelivery - participant Session as sessions row - participant Events as session_events - participant Queue as session_event_queue - - Client->>API: Send Events - API->>API: normalize events/outcome(内存) - API->>DB: events + optional outcome - DB->>Session: SELECT FOR UPDATE - - alt 启动窗口且不是单条 user.message - DB-->>API: startup conflict - API-->>Client: 409,无持久化副作用 - else 启动窗口且 queue 非空 - DB-->>API: startup conflict - API-->>Client: 409,无持久化副作用 - else 启动窗口且允许接收 - DB->>Events: INSERT event - DB->>Queue: INSERT event reference - DB->>DB: optional outcome UPDATE + COMMIT - DB-->>API: startup_queued - API-->>Client: 200 - else 非启动窗口 - DB->>Events: INSERT current batch - DB->>DB: optional outcome UPDATE + COMMIT - DB-->>API: realtime - API->>API: 仅向 active Code Session 投当前 batch - API-->>Client: 200 - end -``` - -由于普通发送与激活都锁 Session: - -- 两个并发启动消息会被串行化; -- 第一个看到空 queue 并返回 200; -- 第二个看到仍然保留的 queue row 并返回 409; -- queue row 一直保留到 Code Session `active` 的同一事务,因此整个启动窗口累计只能成功接收 - 一条普通 `user.message`,不是“同一时刻只有一条 pending”。 - -## Code Session 创建与原子激活 - -`Service.CreateManagedAgentCodeSession` 的顺序为: - -1. 创建状态为 `initializing` 的 Code Session; -2. 写入 sequence 1 的 `initialize` inbound; -3. 调用 `ActivateManagedAgentCodeSession`,在最终激活事务内读取 queue 与完整 - `session_events`,按稳定顺序构造 inbound 并激活; -4. 激活成功后才继续签发并返回 runtime 启动信息; -5. 中途失败时,现有 defer cleanup 将未完成的 Code Session terminate。 - -### 一个事务完成读取、交接与激活 - -`Service.ActivateManagedAgentCodeSession` 通过 -`DB.WithManagedAgentActivationTx` 定义事务边界并固定执行以下顺序;DB 的事务对象只暴露 -Session、queue 和 Code Session 各自的 SQL 操作,不编排跨资源业务流程: - -```text -锁 Session -→ 锁 initializing Code Session -→ 读取并锁定完整 queue,校验均为 user.message 且属于当前 Session -→ 读取当前 Session 的完整公开历史(created_at asc, id asc) -→ 按该稳定顺序过滤、转换可转发 inbound -→ 批量检查幂等键、分配连续 sequence,并按固定大小分块 INSERT -→ 一次更新 Code Session 的 last inbound sequence -→ 删除当前 Session 的全部 queue rows -→ Code Session initializing → active -→ commit -``` - -queue 只承担首条启动 `user.message` 的临时投递责任;queue 中的消息已经属于公开历史, -不会再单独追加。激活先取得 Send Events 使用的同一条 Session 行锁,再读取 queue 与历史, -因此不需要事务外快照、UUID match 或重试循环。 - -`created_at` 保持既有历史时间语义;同一 batch 共用时间戳时,以仅限数据库内部排序的 identity -`id` 保持原始写入顺序。这样 Deployment initial messages、同 batch 消息以及 -`user.message → user.interrupt` 都使用同一个稳定顺序来源。 - -inbound 插入复用现有 idempotency key。事务内先批量加载已存在的幂等键,只为新事件按 -history 输入顺序分配连续 sequence;随后每 500 条执行一次批量 INSERT,并在全部写入成功后 -只更新一次 Code Session 的 last inbound sequence。这样既保持历史与 Deployment initial -messages 的稳定顺序,也避免持有 Session 锁时逐事件执行查询、插入和 sequence 更新。 - -## 激活 cutover 的并发语义 - -发送与激活都先锁同一条 Session 行,只会出现两种提交顺序。 - -```mermaid -sequenceDiagram - autonumber + participant Client participant Send as Send transaction - participant Activate as Activation transaction participant Session as sessions row participant Events as session_events - participant Queue as session_event_queue + participant Activate as Activation transaction participant CS as code_sessions participant Inbound as code_session_inbound_events - alt Send 先获得 Session 锁 + alt Send 先锁 Session Send->>Session: SELECT FOR UPDATE - Activate->>Session: 等待 - Send->>Events: INSERT public event - Send->>Queue: user.message 时 INSERT reference + Activate->>Session: wait + Send->>Events: INSERT current batch Send->>Send: COMMIT - Activate->>Session: 获得锁 - Activate->>Queue: 读取并锁定当前完整 queue - Activate->>Events: 读取包含已提交事件的完整历史 - Activate->>Inbound: 按稳定历史顺序交接 - Activate->>Queue: DELETE all - Activate->>CS: UPDATE active + COMMIT - else Activate 先获得 Session 锁 + Activate->>Session: acquire lock + Activate->>Events: read complete history + Activate->>Inbound: append forwardable events + Activate->>CS: initializing → active + Activate->>Activate: COMMIT + else Activate 先锁 Session Activate->>Session: SELECT FOR UPDATE - Send->>Session: 等待 - Activate->>Queue: 读取并锁定完整 queue - Activate->>Events: 读取当前完整历史 - Activate->>Inbound: INSERT all startup inputs - Activate->>Queue: DELETE all - Activate->>CS: UPDATE active + COMMIT - Send->>Session: 获得锁并看到 active - Send->>Inbound: realtime current batch + Send->>Session: wait + Activate->>Events: read complete history + Activate->>Inbound: append forwardable events + Activate->>CS: initializing → active + Activate->>Activate: COMMIT + Send->>Session: acquire lock + Send->>Events: INSERT current batch + Send->>Inbound: active realtime delivery end ``` -因此不存在可观察的“queue 已被删除,但 Code Session 仍是 `initializing`”窗口:删除 queue 和 -切换 active 属于同一个事务。 - -## Deployment initial events - -Deployment 创建 Session 时,Session、initial events、queue 和 Deployment Run 已位于同一 -事务。该路径: - -1. 按输入顺序写入全部 `session_events`; -2. 如果属于启动窗口,把其中所有 `user.message` 按相同顺序写入 queue; -3. 不应用普通 Send Events 的单条限制; -4. 非 `user.message` 保留为公开事件,但不进入这个窄 queue; -5. 任一步失败都回滚整个 Deployment 创建事务。 - -Code Session 激活事务按稳定历史顺序一次性交接可转发事件,因此 inbound 顺序为: - -```text -initialize -→ initial user message 1 -→ initial user message 2 -→ ... -``` - -## Active 实时路径 - -`AppendSessionEventsForDelivery` 返回 `realtime` 后,API 只把本次创建的 events 传给 -`Service.QueuePublicSessionEvents`。 - -该方法重新读取最新 Code Session,并且只有 `status == active` 时才写 inbound。不存在 Code -Session、仍为 `initializing`、已经 `terminated` 或其他非 active 状态时直接返回,不向其写入 -实时事件。 +## 激活流程 -实时路径继续复用已有的: +`Service.CreateManagedAgentCodeSession`: -- 可转发事件过滤; -- `user.tool_confirmation` control response; -- worker payload envelope; -- inbound idempotency; -- 当前 batch 范围。 +1. 创建 `initializing` Code Session; +2. 写入 `initialize` inbound; +3. 调用 `ActivateManagedAgentCodeSession`; +4. activation 事务锁定 Session 和 initializing Code Session; +5. 按 `created_at ASC, id ASC` 读取完整 `session_events`; +6. 过滤并转换可转发事件,幂等写入 inbound; +7. 将 Code Session 切为 `active` 并提交。 -## 失败与重试语义 +任一历史转换、inbound 写入或状态更新失败时,activation 事务整体回滚,Code Session 保持 +`initializing`。Deployment initial events 已经属于 `session_events`,无需单独交接路径。 -| 失败位置 | 结果 | -| --- | --- | -| 启动期 batch 不是单条 `user.message` | 返回 409;event、queue、outcome 均不写入 | -| 启动期已有 queue row | 返回 409;请求无持久化副作用 | -| event、queue 或 outcome 写入失败 | Send 事务整体回滚 | -| queue event 不属于当前 Session | 创建失败;不写 inbound、不删 queue、不激活 | -| 事件转换失败 | 创建失败;queue 保留 | -| 任一 inbound 写入或 sequence 更新失败 | 激活事务整体回滚,queue 保留,状态仍为 `initializing` | -| active 更新没有恰好影响一行 | 激活事务回滚 | -| Code Session 创建流程失败 | 现有 cleanup terminate 未完成的 Code Session | -| active 后实时投递失败 | 保持既有行为;公开事件已提交,本设计不增加通用 outbox | +## Realtime cutover -## 代码入口 +Send 提交公开事件后始终调用 `Service.QueuePublicSessionEvents`。该方法重新读取最新 Code +Session,只有 `status == active` 时才写 inbound;不存在或仍为 `initializing` 时直接返回。 -| 设计步骤 | 代码入口 | -| --- | --- | -| Runner prepare 不再读取事件快照 | `Runner.prepareManagedAgentLaunch` | -| API 标准化事件与 outcome | `Handler.sendEventsRoute`、`normalizeInputEvent` | -| Send 事务和 startup/realtime 分流 | `DB.AppendSessionEventsForDelivery` | -| 启动交接 SQL 声明与生成实现 | 按主表拆分的 `*Mapper`、对应 `*_mapper.xml` | -| 启动窗口判断 | `shouldQueueForStartup`、`shouldQueueForStartupSQLX` | -| queue 写入 | `enqueueSessionEventsTx`、`enqueueSessionEventsSQLXTx` | -| queue 事务内加载及 ownership / type 校验(不决定 inbound 序) | `ManagedAgentActivationTx.ListSessionEventQueueItems` | -| 激活历史的稳定顺序读取 | `ManagedAgentActivationTx.ListSessionEventsForActivation` | -| 激活 inbound 分块批量写入 | `ManagedAgentActivationTx.AppendCodeSessionInboundEvents` | -| queue、history、inbound 与 active 原子交接 | `Service.ActivateManagedAgentCodeSession`、`DB.WithManagedAgentActivationTx` | -| Deployment initial events 入队 | `DB.CreateManualDeploymentRun` | -| active 当前 batch 投递 | `Service.QueuePublicSessionEvents` | +如果 activation 恰好在公开事件提交后、realtime 检查前完成,同一事件可能同时出现在 +activation 历史和 realtime 尝试中;现有 inbound idempotency key 会保留一份,不会重复投递。 -## 验收矩阵 +## 验收 -| 场景 | 预期 | -| --- | --- | -| Runner prepare 后、Code Session 创建前发送消息 | API 200;event 与 queue 同事务写入;最终进入 inbound | -| 普通启动消息已在 queue,再发送第二条 | 409;不新增 event、queue 或 outcome | -| 两条普通启动消息并发 | Session 行锁串行化;一个 200,一个 409 | -| 启动期多事件 batch 包含 `user.message` | 整体 409,无部分副作用 | -| 被拒绝 batch 包含 `user.define_outcome` | outcome 不变化 | -| 激活等待 Session 锁时新消息先提交 | 激活取得锁后读取最新 queue 与历史并完整交接 | -| 激活取得 Session 锁后收到 `user.interrupt` | Send 等待激活提交,随后看到 active 并走 realtime | -| 启动期依次收到 `user.message`、`user.interrupt` | inbound 保持 message → interrupt 顺序 | -| queue 引用另一个 Session 的 event | 拒绝激活,queue 保留 | -| 已写一条 inbound 后,后续事件转换失败 | 已写 inbound 回滚,queue 全部保留,Code Session 仍 initializing | -| Deployment 包含多条 initial user messages | `initialize` 后按输入顺序写入全部消息 | -| 激活事务先于 Send 获得 Session 锁 | 激活原子提交,后续消息走 realtime | -| Send 事务先于激活获得 Session 锁 | 消息先提交,激活随后读取最新 queue 与历史 | -| Code Session 已 active | 不写 startup queue,只实时投当前 batch | -| Code Session 非 active | 不实时写 inbound | -| Environment 类型为 self_hosted 等 | 与 cloud 相同:只要 CS 未 active 且存在指向该 Session 的在途 work,即可能进 startup queue;**不**因 environment type 跳过 queue | +- Runner prepare 后、Code Session 创建前接受的消息最终进入 inbound; +- 启动期接受多条用户消息,activation 按公开历史顺序全部重放; +- activation 失败时不留下部分 inbound,也不切换为 active; +- activation 后的新 batch 只通过 realtime 路径追加; +- Deployment initial user messages 在 `initialize` 后按输入顺序进入 inbound。 diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 28f94751..32cc04d0 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -125,9 +125,8 @@ func (s *Service) CreateManagedAgentCodeSession(ctx context.Context, input Manag }, nil } -// ActivateManagedAgentCodeSession locks the owning Session, loads the -// startup queue and complete public history, writes forwardable events in -// stable order, clears the queue, and activates the Code Session atomically. +// ActivateManagedAgentCodeSession locks the owning Session, replays complete +// public history in stable order, and activates the Code Session atomically. func (s *Service) ActivateManagedAgentCodeSession( ctx context.Context, codeSession db.CodeSession, @@ -150,18 +149,6 @@ func (s *Service) ActivateManagedAgentCodeSession( if err != nil { return err } - // Queue rows are only a startup-delivery responsibility check. Inbound - // payloads and order come from locked public history below; after that - // succeeds the queue is cleared as the handoff completes. - queuedEvents, err := tx.ListSessionEventQueueItems(ctx, lockedSession) - if err != nil { - return err - } - for _, event := range queuedEvents { - if event.EventType != "user.message" { - return db.ErrInvalidState - } - } sessionEvents, err := tx.ListSessionEventsForActivation(ctx, lockedSession) if err != nil { return err @@ -180,9 +167,6 @@ func (s *Service) ActivateManagedAgentCodeSession( if err := tx.AppendCodeSessionInboundEvents(ctx, lockedCodeSession, inboundInputs); err != nil { return err } - if err := tx.DeleteSessionEventQueue(ctx, lockedSession.UUID); err != nil { - return err - } statusUpdated, err := tx.ActivateCodeSession(ctx, lockedCodeSession.UUID, time.Now().UTC()) if err != nil { return err diff --git a/internal/db/db.go b/internal/db/db.go index 5c85dc9f..9da27bce 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -24,21 +24,19 @@ import ( ) var ( - ErrNotFound = platform.ErrNotFound - ErrInvalidState = errors.New("invalid state") - ErrPreconditionFailed = errors.New("precondition failed") - ErrDuplicate = errors.New("duplicate") - ErrVersionConflict = errors.New("version conflict") - ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") - ErrWorkerNotRegistered = errors.New("worker not registered") - ErrWorkerLeaseExpired = errors.New("worker lease expired") - ErrStorageLimitExceeded = errors.New("storage limit exceeded") - ErrStorageUsageUnderflow = errors.New("storage usage underflow") - ErrLimitExceeded = errors.New("limit exceeded") - ErrFileInUse = errors.New("file is in use") - ErrFileReferenceNotFound = errors.New("file reference not found") - ErrSessionArchived = errors.New("session is archived") - ErrSessionStartupMessageConflict = errors.New("session startup message conflict") + ErrNotFound = platform.ErrNotFound + ErrInvalidState = errors.New("invalid state") + ErrPreconditionFailed = errors.New("precondition failed") + ErrDuplicate = errors.New("duplicate") + ErrVersionConflict = errors.New("version conflict") + ErrWorkerEpochMismatch = errors.New("worker epoch mismatch") + ErrWorkerNotRegistered = errors.New("worker not registered") + ErrWorkerLeaseExpired = errors.New("worker lease expired") + ErrStorageLimitExceeded = errors.New("storage limit exceeded") + ErrStorageUsageUnderflow = errors.New("storage usage underflow") + ErrLimitExceeded = errors.New("limit exceeded") + ErrFileInUse = errors.New("file is in use") + ErrFileReferenceNotFound = errors.New("file reference not found") ) type DB struct { diff --git a/internal/db/deployments.go b/internal/db/deployments.go index 389b693e..44b4705a 100644 --- a/internal/db/deployments.go +++ b/internal/db/deployments.go @@ -280,15 +280,6 @@ func (d *DB) CreateManualDeploymentRun(ctx context.Context, input CreateManualDe if err != nil { return DeploymentRun{}, Session{}, SessionThread{}, nil, err } - shouldEnqueue, err := shouldQueueForStartupSQLX(ctx, tx, session) - if err != nil { - return DeploymentRun{}, Session{}, SessionThread{}, nil, err - } - if shouldEnqueue { - if err := enqueueSessionEventsSQLXTx(ctx, tx, session, events); err != nil { - return DeploymentRun{}, Session{}, SessionThread{}, nil, err - } - } run := input.Run run.DeploymentUUID = deployment.UUID diff --git a/internal/db/environment_work_mapper.go b/internal/db/environment_work_mapper.go deleted file mode 100644 index 05a6f473..00000000 --- a/internal/db/environment_work_mapper.go +++ /dev/null @@ -1,19 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" -) - -//go:generate go tool sqlmapgen -mapper EnvironmentWorkMapper -sql ./environment_work_mapper.xml -dialect postgres - -// EnvironmentWorkMapper contains queries whose primary table is environment_work. -type EnvironmentWorkMapper interface { - StartupEnvironmentWorkExists( - ctx context.Context, - workspaceUUID uuid.UUID, - environmentUUID uuid.UUID, - sessionExternalID string, - ) (bool, error) -} diff --git a/internal/db/environment_work_mapper.xml b/internal/db/environment_work_mapper.xml deleted file mode 100644 index c5a067e8..00000000 --- a/internal/db/environment_work_mapper.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/internal/db/managed_agent_activation.go b/internal/db/managed_agent_activation.go index db3f3efb..bd1414e9 100644 --- a/internal/db/managed_agent_activation.go +++ b/internal/db/managed_agent_activation.go @@ -13,7 +13,6 @@ type ManagedAgentActivationTx struct { codeSessionInboundEventMapper CodeSessionInboundEventMapper sessionMapper SessionMapper sessionEventMapper SessionEventMapper - sessionEventQueueMapper SessionEventQueueMapper } // WithManagedAgentActivationTx owns the database transaction lifecycle while @@ -28,7 +27,27 @@ func (d *DB) WithManagedAgentActivationTx( codeSessionInboundEventMapper: NewCodeSessionInboundEventMapper(executor), sessionMapper: NewSessionMapper(executor), sessionEventMapper: NewSessionEventMapper(executor), - sessionEventQueueMapper: NewSessionEventQueueMapper(executor), }) }) } + +// ListSessionEventsForActivation returns complete public history in insertion order. +func (tx ManagedAgentActivationTx) ListSessionEventsForActivation( + ctx context.Context, + session Session, +) ([]SessionEvent, error) { + rows, err := tx.sessionEventMapper.ListSessionEventsForActivation( + ctx, + session.OrganizationUUID, + session.WorkspaceUUID, + session.UUID, + ) + if err != nil { + return nil, err + } + events := make([]SessionEvent, len(rows)) + for i, row := range rows { + events[i] = row.event() + } + return events, nil +} diff --git a/internal/db/migrations/00047_add_session_event_queue.sql b/internal/db/migrations/00047_add_session_event_queue.sql deleted file mode 100644 index ded593cd..00000000 --- a/internal/db/migrations/00047_add_session_event_queue.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up -create table session_event_queue ( - id bigint generated always as identity, - uuid uuid not null default gen_random_uuid(), - organization_uuid uuid not null, - workspace_uuid uuid not null, - session_uuid uuid not null, - session_event_uuid uuid not null, - created_at timestamptz not null default now(), - constraint session_event_queue_id_pk primary key (id), - constraint session_event_queue_uuid_key unique (uuid), - constraint session_event_queue_session_event_uuid_key unique (session_event_uuid) -); - -create index session_event_queue_session_order_v1_idx - on session_event_queue (organization_uuid, workspace_uuid, session_uuid, id asc); - --- +goose Down -drop table if exists session_event_queue; diff --git a/internal/db/session_event_mapper.go b/internal/db/session_event_mapper.go index 4a2bb371..56f0cceb 100644 --- a/internal/db/session_event_mapper.go +++ b/internal/db/session_event_mapper.go @@ -2,29 +2,16 @@ package db import ( "context" - - "github.com/google/uuid" ) //go:generate go tool sqlmapgen -mapper SessionEventMapper -sql ./session_event_mapper.xml -dialect postgres // SessionEventMapper contains queries whose primary table is session_events. type SessionEventMapper interface { - InsertSessionEvent( - ctx context.Context, - row sessionEventInsertRow, - ) (sessionEventRow, error) - ListSessionEventsForActivation( ctx context.Context, - organizationUUID uuid.UUID, - workspaceUUID uuid.UUID, - sessionUUID uuid.UUID, - ) ([]sessionEventRow, error) - - ListSessionEventsByUUIDs( - ctx context.Context, - sessionUUID uuid.UUID, - sessionEventUUIDs []uuid.UUID, + organizationUUID string, + workspaceUUID string, + sessionUUID string, ) ([]sessionEventRow, error) } diff --git a/internal/db/session_event_mapper.xml b/internal/db/session_event_mapper.xml index 5173afb6..5e26a3c7 100644 --- a/internal/db/session_event_mapper.xml +++ b/internal/db/session_event_mapper.xml @@ -20,41 +20,7 @@ deleted_at - - INSERT INTO session_events ( - uuid, - external_id, - organization_uuid, - workspace_uuid, - session_uuid, - session_external_id, - thread_uuid, - thread_external_id, - event_type, - payload, - processed_at, - created_at - ) VALUES ( - #{row.UUID}, - #{row.ExternalID}, - #{row.OrganizationUUID}, - #{row.WorkspaceUUID}, - #{row.SessionUUID}, - #{row.SessionExternalID}, - #{row.ThreadUUID}, - #{row.ThreadExternalID}, - #{row.EventType}, - CAST(#{row.Payload} AS jsonb), - #{row.ProcessedAt}, - #{row.CreatedAt} - ) - RETURNING - - - - SELECT FROM session_events @@ -64,17 +30,4 @@ AND deleted_at IS NULL ORDER BY created_at ASC, id ASC - - diff --git a/internal/db/session_event_queue.go b/internal/db/session_event_queue.go deleted file mode 100644 index 21215036..00000000 --- a/internal/db/session_event_queue.go +++ /dev/null @@ -1,461 +0,0 @@ -package db - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/samber/lo" - "github.com/superduck-ai/yourbatis" -) - -type SessionEventDelivery string - -const ( - SessionEventDeliveryRealtime SessionEventDelivery = "realtime" - SessionEventDeliveryStartupQueued SessionEventDelivery = "startup_queued" -) - -type sessionEventQueueIdentityRow struct { - ID int64 `db:"id"` - SessionEventUUID uuid.UUID `db:"session_event_uuid"` -} - -type sessionEventQueueInsertRow struct { - OrganizationUUID uuid.UUID `db:"organization_uuid"` - WorkspaceUUID uuid.UUID `db:"workspace_uuid"` - SessionUUID uuid.UUID `db:"session_uuid"` - SessionEventUUID uuid.UUID `db:"session_event_uuid"` -} - -type sessionUUIDs struct { - OrganizationUUID uuid.UUID - WorkspaceUUID uuid.UUID - SessionUUID uuid.UUID - EnvironmentUUID uuid.UUID -} - -type sessionEventInsertRow struct { - UUID uuid.UUID `db:"uuid"` - ExternalID string `db:"external_id"` - OrganizationUUID uuid.UUID `db:"organization_uuid"` - WorkspaceUUID uuid.UUID `db:"workspace_uuid"` - SessionUUID uuid.UUID `db:"session_uuid"` - SessionExternalID string `db:"session_external_id"` - ThreadUUID uuid.UUID `db:"thread_uuid"` - ThreadExternalID string `db:"thread_external_id"` - EventType string `db:"event_type"` - Payload []byte `db:"payload"` - ProcessedAt time.Time `db:"processed_at"` - CreatedAt time.Time `db:"created_at"` -} - -// AppendSessionEventsForDelivery keeps the existing delivery path outside the -// managed-agent startup window. During startup it accepts exactly one -// user.message with an empty queue and records the public event and temporary -// delivery responsibility in the same transaction. -func (d *DB) AppendSessionEventsForDelivery( - ctx context.Context, - workspaceUUID string, - sessionExternalID string, - events []SessionEvent, - outcomeEvaluations json.RawMessage, -) ([]SessionEvent, SessionEventDelivery, error) { - var created []SessionEvent - delivery := SessionEventDeliveryRealtime - err := d.mapperDB.Transaction(ctx, func(executor yourbatis.Executor) error { - sessionMapper := NewSessionMapper(executor) - sessionThreadMapper := NewSessionThreadMapper(executor) - sessionEventMapper := NewSessionEventMapper(executor) - sessionEventQueueMapper := NewSessionEventQueueMapper(executor) - codeSessionMapper := NewCodeSessionMapper(executor) - environmentWorkMapper := NewEnvironmentWorkMapper(executor) - parsedWorkspaceUUID, err := parseDBUUID("workspace_uuid", workspaceUUID) - if err != nil { - return err - } - sessionRow, found, err := sessionMapper.LockSessionForEvents( - ctx, - parsedWorkspaceUUID, - sessionExternalID, - ) - if err != nil { - return err - } - if !found { - return ErrNotFound - } - session := sessionRow.session() - if session.ArchivedAt != nil { - return ErrSessionArchived - } - userMessageCount := lo.CountBy(events, func(event SessionEvent) bool { - return event.EventType == "user.message" - }) - shouldEnqueue := false - if userMessageCount > 0 { - shouldEnqueue, err = shouldQueueForStartup( - ctx, - codeSessionMapper, - environmentWorkMapper, - session, - ) - if err != nil { - return err - } - } - if shouldEnqueue { - if len(events) != 1 || userMessageCount != 1 { - return ErrSessionStartupMessageConflict - } - sessionUUID, err := parseDBUUID("session_uuid", session.UUID) - if err != nil { - return err - } - hasQueuedEvents, err := sessionEventQueueMapper.SessionEventQueueExists(ctx, sessionUUID) - if err != nil { - return err - } - if hasQueuedEvents { - return ErrSessionStartupMessageConflict - } - } - - created, err = insertSessionEventsWithMappers( - ctx, - sessionThreadMapper, - sessionEventMapper, - session, - events, - ) - if err != nil { - return err - } - if shouldEnqueue { - if err := enqueueSessionEventsTx( - ctx, - sessionEventQueueMapper, - session, - created, - ); err != nil { - return err - } - delivery = SessionEventDeliveryStartupQueued - } - if len(outcomeEvaluations) > 0 { - updated, err := sessionMapper.SetSessionOutcomeEvaluations( - ctx, - parsedWorkspaceUUID, - session.ExternalID, - outcomeEvaluations, - ) - if err != nil { - return err - } - if updated == 0 { - return ErrNotFound - } - if updated != 1 { - return ErrInvalidState - } - } - return nil - }) - if err != nil { - return nil, "", err - } - return created, delivery, nil -} - -func insertSessionEventsWithMappers( - ctx context.Context, - sessionThreadMapper SessionThreadMapper, - sessionEventMapper SessionEventMapper, - session Session, - events []SessionEvent, -) ([]SessionEvent, error) { - parsedUUIDs, err := parseSessionUUIDs(session) - if err != nil { - return nil, err - } - primaryRow, found, err := sessionThreadMapper.GetPrimarySessionThread( - ctx, - parsedUUIDs.WorkspaceUUID, - session.ExternalID, - ) - if err != nil { - return nil, err - } - if !found { - return nil, ErrNotFound - } - primary := primaryRow.thread() - - created := make([]SessionEvent, 0, len(events)) - for _, input := range events { - event := input - event.OrganizationUUID = session.OrganizationUUID - event.WorkspaceUUID = session.WorkspaceUUID - event.SessionUUID = session.UUID - event.SessionExternalID = session.ExternalID - - thread := primary - if event.ThreadExternalID != nil { - threadRow, found, err := sessionThreadMapper.GetSessionThreadByExternalID( - ctx, - parsedUUIDs.WorkspaceUUID, - session.ExternalID, - *event.ThreadExternalID, - ) - if err != nil { - return nil, err - } - if !found { - return nil, ErrNotFound - } - thread = threadRow.thread() - } - threadUUID, err := parseDBUUID("thread_uuid", thread.UUID) - if err != nil { - return nil, err - } - eventUUID, err := parseDBUUID("event_uuid", event.UUID) - if err != nil { - return nil, err - } - row, err := sessionEventMapper.InsertSessionEvent(ctx, sessionEventInsertRow{ - UUID: eventUUID, - ExternalID: event.ExternalID, - OrganizationUUID: parsedUUIDs.OrganizationUUID, - WorkspaceUUID: parsedUUIDs.WorkspaceUUID, - SessionUUID: parsedUUIDs.SessionUUID, - SessionExternalID: session.ExternalID, - ThreadUUID: threadUUID, - ThreadExternalID: thread.ExternalID, - EventType: event.EventType, - Payload: []byte(event.Payload), - ProcessedAt: event.ProcessedAt, - CreatedAt: event.CreatedAt, - }) - if err != nil { - return nil, err - } - created = append(created, row.event()) - } - return created, nil -} - -// ListSessionEventQueueItems returns and locks the public events currently -// referenced by the startup queue for the Session already locked by the -// activation transaction. Callers use this only to validate ownership and -// event type before clearing the queue; inbound content and order come from -// ListSessionEventsForActivation. -func (tx ManagedAgentActivationTx) ListSessionEventQueueItems( - ctx context.Context, - session Session, -) ([]SessionEvent, error) { - return listSessionEventQueueItems( - ctx, - tx.sessionEventQueueMapper, - tx.sessionEventMapper, - session, - ) -} - -func listSessionEventQueueItems( - ctx context.Context, - sessionEventQueueMapper SessionEventQueueMapper, - sessionEventMapper SessionEventMapper, - session Session, -) ([]SessionEvent, error) { - sessionUUID, err := parseDBUUID("session_uuid", session.UUID) - if err != nil { - return nil, err - } - identityRows, err := sessionEventQueueMapper.ListSessionEventQueueIdentities(ctx, sessionUUID) - if err != nil { - return nil, err - } - if len(identityRows) == 0 { - return nil, nil - } - - eventsByUUID, err := sessionEventsByUUIDs(ctx, sessionEventMapper, session, identityRows) - if err != nil { - return nil, err - } - - events := make([]SessionEvent, 0, len(identityRows)) - for _, row := range identityRows { - event, ok := eventsByUUID[row.SessionEventUUID.String()] - if !ok { - return nil, fmt.Errorf( - "%w: queued event %s does not belong to Session %s", - ErrInvalidState, - row.SessionEventUUID.String(), - session.ExternalID, - ) - } - events = append(events, event) - } - return events, nil -} - -// ListSessionEventsForActivation returns complete public history in stable -// creation order. The database identity is used only to preserve insertion -// order when a batch shares one created_at timestamp. -func (tx ManagedAgentActivationTx) ListSessionEventsForActivation( - ctx context.Context, - session Session, -) ([]SessionEvent, error) { - parsedUUIDs, err := parseSessionUUIDs(session) - if err != nil { - return nil, err - } - rows, err := tx.sessionEventMapper.ListSessionEventsForActivation( - ctx, - parsedUUIDs.OrganizationUUID, - parsedUUIDs.WorkspaceUUID, - parsedUUIDs.SessionUUID, - ) - if err != nil { - return nil, err - } - return lo.Map(rows, func(row sessionEventRow, _ int) SessionEvent { - return row.event() - }), nil -} - -func sessionEventsByUUIDs( - ctx context.Context, - sessionEventMapper SessionEventMapper, - session Session, - identityRows []sessionEventQueueIdentityRow, -) (map[string]SessionEvent, error) { - eventUUIDs := lo.Map(identityRows, func(row sessionEventQueueIdentityRow, _ int) uuid.UUID { - return row.SessionEventUUID - }) - sessionUUID, err := parseDBUUID("session_uuid", session.UUID) - if err != nil { - return nil, err - } - rows, err := sessionEventMapper.ListSessionEventsByUUIDs( - ctx, - sessionUUID, - eventUUIDs, - ) - if err != nil { - return nil, err - } - events := lo.Map(rows, func(row sessionEventRow, _ int) SessionEvent { - return row.event() - }) - byUUID := make(map[string]SessionEvent, len(events)) - for _, event := range events { - byUUID[event.UUID] = event - } - return byUUID, nil -} - -func (tx ManagedAgentActivationTx) DeleteSessionEventQueue( - ctx context.Context, - sessionUUID string, -) error { - parsedUUID, err := parseDBUUID("session_uuid", sessionUUID) - if err != nil { - return err - } - _, err = tx.sessionEventQueueMapper.DeleteSessionEventQueue(ctx, parsedUUID) - return err -} - -// shouldQueueForStartup reports whether a user.message should enter the startup -// queue. Environment type is intentionally not part of this decision: queueing -// depends only on whether the Session still has no active Code Session and still -// has session-scoped environment work in flight. -func shouldQueueForStartup( - ctx context.Context, - codeSessionMapper CodeSessionMapper, - environmentWorkMapper EnvironmentWorkMapper, - session Session, -) (bool, error) { - parsedUUIDs, err := parseSessionUUIDs(session) - if err != nil { - return false, err - } - status, found, err := codeSessionMapper.GetLatestCodeSessionStatus(ctx, parsedUUIDs.SessionUUID) - if err != nil { - return false, err - } - if found && status != "initializing" { - return false, nil - } - return environmentWorkMapper.StartupEnvironmentWorkExists( - ctx, - parsedUUIDs.WorkspaceUUID, - parsedUUIDs.EnvironmentUUID, - session.ExternalID, - ) -} - -func enqueueSessionEventsTx( - ctx context.Context, - sessionEventQueueMapper SessionEventQueueMapper, - session Session, - events []SessionEvent, -) error { - parsedUUIDs, err := parseSessionUUIDs(session) - if err != nil { - return err - } - - rows := make([]sessionEventQueueInsertRow, 0, len(events)) - for _, event := range events { - if event.EventType != "user.message" { - continue - } - eventUUID, err := parseDBUUID("session_event_uuid", event.UUID) - if err != nil { - return err - } - rows = append(rows, sessionEventQueueInsertRow{ - OrganizationUUID: parsedUUIDs.OrganizationUUID, - WorkspaceUUID: parsedUUIDs.WorkspaceUUID, - SessionUUID: parsedUUIDs.SessionUUID, - SessionEventUUID: eventUUID, - }) - } - if len(rows) == 0 { - return nil - } - - _, err = sessionEventQueueMapper.EnqueueSessionEvents(ctx, rows) - return err -} - -func parseSessionUUIDs(session Session) (sessionUUIDs, error) { - organizationUUID, err := parseDBUUID("organization_uuid", session.OrganizationUUID) - if err != nil { - return sessionUUIDs{}, err - } - workspaceUUID, err := parseDBUUID("workspace_uuid", session.WorkspaceUUID) - if err != nil { - return sessionUUIDs{}, err - } - sessionUUID, err := parseDBUUID("session_uuid", session.UUID) - if err != nil { - return sessionUUIDs{}, err - } - environmentUUID, err := parseDBUUID("environment_uuid", session.EnvironmentUUID) - if err != nil { - return sessionUUIDs{}, err - } - return sessionUUIDs{ - OrganizationUUID: organizationUUID, - WorkspaceUUID: workspaceUUID, - SessionUUID: sessionUUID, - EnvironmentUUID: environmentUUID, - }, nil -} diff --git a/internal/db/session_event_queue_mapper.go b/internal/db/session_event_queue_mapper.go deleted file mode 100644 index b2cd8bbe..00000000 --- a/internal/db/session_event_queue_mapper.go +++ /dev/null @@ -1,33 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" -) - -//go:generate go tool sqlmapgen -mapper SessionEventQueueMapper -sql ./session_event_queue_mapper.xml -dialect postgres - -// SessionEventQueueMapper contains queries whose primary table is -// session_event_queue. -type SessionEventQueueMapper interface { - ListSessionEventQueueIdentities( - ctx context.Context, - sessionUUID uuid.UUID, - ) ([]sessionEventQueueIdentityRow, error) - - DeleteSessionEventQueue( - ctx context.Context, - sessionUUID uuid.UUID, - ) (int64, error) - - SessionEventQueueExists( - ctx context.Context, - sessionUUID uuid.UUID, - ) (bool, error) - - EnqueueSessionEvents( - ctx context.Context, - rows []sessionEventQueueInsertRow, - ) (int64, error) -} diff --git a/internal/db/session_event_queue_mapper.xml b/internal/db/session_event_queue_mapper.xml deleted file mode 100644 index 942337e5..00000000 --- a/internal/db/session_event_queue_mapper.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - DELETE FROM session_event_queue - WHERE session_uuid = #{sessionUUID} - - - - - - INSERT INTO session_event_queue ( - organization_uuid, - workspace_uuid, - session_uuid, - session_event_uuid - ) VALUES - - ( - #{row.OrganizationUUID}, - #{row.WorkspaceUUID}, - #{row.SessionUUID}, - #{row.SessionEventUUID} - ) - - - diff --git a/internal/db/session_event_queue_sqlx.go b/internal/db/session_event_queue_sqlx.go deleted file mode 100644 index f949ebe9..00000000 --- a/internal/db/session_event_queue_sqlx.go +++ /dev/null @@ -1,104 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" -) - -const ( - deleteSessionEventQueueQuery = ` - delete from session_event_queue - where session_uuid = :session_uuid - ` - latestCodeSessionStatusForStartupQuery = ` - select status - from code_sessions - where session_uuid = :session_uuid - and deleted_at is null - order by created_at desc, uuid desc - limit 1 - ` - startupEnvironmentWorkExistsQuery = ` - select exists ( - select 1 - from environment_work ew - where ew.workspace_uuid = :workspace_uuid - and ew.environment_uuid = :environment_uuid - and ew.data->>'type' = 'session' - and ew.data->>'id' = :session_external_id - and ew.state in ('queued', 'starting', 'active') - and ew.deleted_at is null - ) as exists - ` - enqueueSessionEventQuery = ` - insert into session_event_queue ( - organization_uuid, workspace_uuid, session_uuid, session_event_uuid - ) values ( - :organization_uuid, :workspace_uuid, :session_uuid, :session_event_uuid - ) - ` -) - -// These sqlx helpers are intentionally limited to legacy transactions whose -// other statements have not moved to yourbatis. Keeping the queue operation on -// the same sqlx transaction preserves atomicity without adapting sqlx.Tx into a -// custom yourbatis Executor. -func deleteSessionEventQueueSQLX( - ctx context.Context, - database sqlxNamedExecer, - sessionUUID string, -) error { - _, err := namedExecContext(ctx, database, deleteSessionEventQueueQuery, map[string]any{ - "session_uuid": dbUUID(sessionUUID), - }) - return err -} - -func shouldQueueForStartupSQLX( - ctx context.Context, - database sqlxNamedQueryer, - session Session, -) (bool, error) { - arguments := map[string]any{ - "session_uuid": dbUUID(session.UUID), - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "environment_uuid": dbUUID(session.EnvironmentUUID), - "session_external_id": session.ExternalID, - } - var status string - err := namedGetContext(ctx, database, &status, latestCodeSessionStatusForStartupQuery, arguments) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return false, err - } - if err == nil && status != "initializing" { - return false, nil - } - - var exists bool - err = namedGetContext(ctx, database, &exists, startupEnvironmentWorkExistsQuery, arguments) - return exists, err -} - -func enqueueSessionEventsSQLXTx( - ctx context.Context, - database sqlxNamedExecer, - session Session, - events []SessionEvent, -) error { - for _, event := range events { - if event.EventType != "user.message" { - continue - } - _, err := namedExecContext(ctx, database, enqueueSessionEventQuery, map[string]any{ - "organization_uuid": dbUUID(session.OrganizationUUID), - "workspace_uuid": dbUUID(session.WorkspaceUUID), - "session_uuid": dbUUID(session.UUID), - "session_event_uuid": dbUUID(event.UUID), - }) - if err != nil { - return err - } - } - return nil -} diff --git a/internal/db/session_mapper.go b/internal/db/session_mapper.go index 101fdff6..9abdf916 100644 --- a/internal/db/session_mapper.go +++ b/internal/db/session_mapper.go @@ -2,8 +2,6 @@ package db import ( "context" - - "github.com/google/uuid" ) //go:generate go tool sqlmapgen -mapper SessionMapper -sql ./session_mapper.xml -dialect postgres @@ -12,14 +10,7 @@ import ( type SessionMapper interface { LockSessionForEvents( ctx context.Context, - workspaceUUID uuid.UUID, + workspaceUUID string, sessionExternalID string, ) (sessionRow, bool, error) - - SetSessionOutcomeEvaluations( - ctx context.Context, - workspaceUUID uuid.UUID, - sessionExternalID string, - outcomeEvaluations []byte, - ) (int64, error) } diff --git a/internal/db/session_mapper.xml b/internal/db/session_mapper.xml index 2fc9f159..6fd5abce 100644 --- a/internal/db/session_mapper.xml +++ b/internal/db/session_mapper.xml @@ -36,13 +36,4 @@ AND deleted_at IS NULL FOR UPDATE - - - UPDATE sessions - SET outcome_evaluations = CAST(#{outcomeEvaluations} AS jsonb), - updated_at = NOW() - WHERE workspace_uuid = #{workspaceUUID} - AND external_id = #{sessionExternalID} - AND deleted_at IS NULL - diff --git a/internal/db/session_thread_mapper.go b/internal/db/session_thread_mapper.go deleted file mode 100644 index 84ca56ae..00000000 --- a/internal/db/session_thread_mapper.go +++ /dev/null @@ -1,25 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" -) - -//go:generate go tool sqlmapgen -mapper SessionThreadMapper -sql ./session_thread_mapper.xml -dialect postgres - -// SessionThreadMapper contains queries whose primary table is session_threads. -type SessionThreadMapper interface { - GetPrimarySessionThread( - ctx context.Context, - workspaceUUID uuid.UUID, - sessionExternalID string, - ) (sessionThreadRow, bool, error) - - GetSessionThreadByExternalID( - ctx context.Context, - workspaceUUID uuid.UUID, - sessionExternalID string, - threadExternalID string, - ) (sessionThreadRow, bool, error) -} diff --git a/internal/db/session_thread_mapper.xml b/internal/db/session_thread_mapper.xml deleted file mode 100644 index a5feaed9..00000000 --- a/internal/db/session_thread_mapper.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - uuid, - external_id, - organization_uuid, - workspace_uuid, - session_uuid, - session_external_id, - parent_thread_uuid, - parent_thread_external_id, - agent_snapshot, - status, - usage, - stats, - created_at, - updated_at, - archived_at, - deleted_at - - - - - - diff --git a/internal/db/sessions.go b/internal/db/sessions.go index e6a8fe57..9494bf1d 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -329,9 +329,6 @@ func (d *DB) DeleteSession(ctx context.Context, workspaceUUID string, externalID if _, err := namedExecContext(ctx, tx, deleteSessionResourcesQuery, arguments); err != nil { return Session{}, err } - if err := deleteSessionEventQueueSQLX(ctx, tx, session.UUID); err != nil { - return Session{}, err - } if _, err := namedExecContext(ctx, tx, deleteSessionEventsQuery, arguments); err != nil { return Session{}, err } @@ -637,7 +634,13 @@ func (d *DB) DeleteSessionResource(ctx context.Context, workspaceUUID string, se return tx.Commit() } -func (d *DB) AppendSessionEvents(ctx context.Context, workspaceUUID string, sessionExternalID string, events []SessionEvent) ([]SessionEvent, error) { +func (d *DB) AppendSessionEvents( + ctx context.Context, + workspaceUUID string, + sessionExternalID string, + events []SessionEvent, + outcomeEvaluations json.RawMessage, +) ([]SessionEvent, error) { tx, err := d.sql.BeginTxx(ctx, nil) if err != nil { return nil, err @@ -655,6 +658,15 @@ func (d *DB) AppendSessionEvents(ctx context.Context, workspaceUUID string, sess if err != nil { return nil, err } + if len(outcomeEvaluations) > 0 { + if _, err := getSessionSQLX(ctx, tx, setSessionOutcomeEvaluationsQuery, map[string]any{ + "workspace_uuid": dbUUID(session.WorkspaceUUID), + "session_external_id": session.ExternalID, + "outcome_evaluations": jsonArg(outcomeEvaluations), + }); err != nil { + return nil, err + } + } if err := tx.Commit(); err != nil { return nil, err } diff --git a/internal/db/sessions_migration_sqlx_test.go b/internal/db/sessions_migration_sqlx_test.go index f6c47dd2..b2770401 100644 --- a/internal/db/sessions_migration_sqlx_test.go +++ b/internal/db/sessions_migration_sqlx_test.go @@ -112,14 +112,6 @@ func TestMigratedSessionQueriesBindNamedArguments(t *testing.T) { arguments: sessionLookupArguments("00000000-0000-0000-0000-000000000002", "sesn_test"), wantArgCount: 2, }, - { - name: "delete session event queue", - query: deleteSessionEventQueueQuery, - arguments: map[string]any{ - "session_uuid": "11111111-1111-4111-8111-111111111111", - }, - wantArgCount: 1, - }, { name: "delete session events", query: deleteSessionEventsQuery, diff --git a/internal/db/sessions_sqlx.go b/internal/db/sessions_sqlx.go index 135df689..ecf0c0d9 100644 --- a/internal/db/sessions_sqlx.go +++ b/internal/db/sessions_sqlx.go @@ -354,13 +354,9 @@ func (tx ManagedAgentActivationTx) LockSessionForEvents( workspaceUUID string, sessionExternalID string, ) (Session, error) { - parsedWorkspaceUUID, err := parseDBUUID("workspace_uuid", workspaceUUID) - if err != nil { - return Session{}, err - } row, found, err := tx.sessionMapper.LockSessionForEvents( ctx, - parsedWorkspaceUUID, + workspaceUUID, sessionExternalID, ) if err != nil { diff --git a/internal/db/yourbatis_mappers_test.go b/internal/db/yourbatis_mappers_test.go index d97ad969..09adc7a2 100644 --- a/internal/db/yourbatis_mappers_test.go +++ b/internal/db/yourbatis_mappers_test.go @@ -1,7 +1,6 @@ package db import ( - "context" "strings" "testing" "time" @@ -10,19 +9,9 @@ import ( yourbatis "github.com/superduck-ai/yourbatis" ) -func TestTableMappersRejectInvalidUUIDBeforeExecution(t *testing.T) { - err := (ManagedAgentActivationTx{}).DeleteSessionEventQueue(context.Background(), "not-a-uuid") - if err == nil || !strings.Contains(err.Error(), "session_uuid must be a non-nil UUID") { - t.Fatalf("DeleteSessionEventQueue() error = %v", err) - } -} - func TestTableMappersBuildDynamicQueries(t *testing.T) { organizationUUID := uuid.MustParse("11111111-1111-4111-8111-111111111111") workspaceUUID := uuid.MustParse("22222222-2222-4222-8222-222222222222") - sessionUUID := uuid.MustParse("33333333-3333-4333-8333-333333333333") - eventUUIDOne := uuid.MustParse("44444444-4444-4444-8444-444444444444") - eventUUIDTwo := uuid.MustParse("55555555-5555-4555-8555-555555555555") t.Run("single code session event append", func(t *testing.T) { lockBound := buildCodeSessionMapperLockCodeSessionByExternalID( @@ -64,80 +53,12 @@ func TestTableMappersBuildDynamicQueries(t *testing.T) { "idempotencyKey", }) }) - - t.Run("event UUIDs", func(t *testing.T) { - bound := buildSessionEventMapperListSessionEventsByUUIDs( - yourbatis.DialectPostgres, - sessionUUID, - []uuid.UUID{eventUUIDOne, eventUUIDTwo}, - ) - assertMapperSQLContains(t, bound, "uuid IN ( $1 , $2 )") - assertMapperSQLContains(t, bound, "session_uuid = $3") - assertMapperArgumentNames(t, bound, []string{ - "sessionEventUUID", - "sessionEventUUID", - "sessionUUID", - }) - }) - - t.Run("queue batch", func(t *testing.T) { - bound := buildSessionEventQueueMapperEnqueueSessionEvents( - yourbatis.DialectPostgres, - []sessionEventQueueInsertRow{ - { - OrganizationUUID: organizationUUID, - WorkspaceUUID: workspaceUUID, - SessionUUID: sessionUUID, - SessionEventUUID: eventUUIDOne, - }, - { - OrganizationUUID: organizationUUID, - WorkspaceUUID: workspaceUUID, - SessionUUID: sessionUUID, - SessionEventUUID: eventUUIDTwo, - }, - }, - ) - assertMapperSQLContains(t, bound, "( $1, $2, $3, $4 ) , ( $5, $6, $7, $8 )") - if len(bound.Args) != 8 { - t.Fatalf("queue batch argument count = %d, want 8", len(bound.Args)) - } - }) - - t.Run("queue exists", func(t *testing.T) { - bound := buildSessionEventQueueMapperSessionEventQueueExists( - yourbatis.DialectPostgres, - sessionUUID, - ) - assertMapperSQLEquals( - t, - bound, - "SELECT EXISTS ( SELECT 1 FROM session_event_queue WHERE session_uuid = $1 )", - ) - assertMapperArgumentNames(t, bound, []string{"sessionUUID"}) - }) - - t.Run("queue identities", func(t *testing.T) { - bound := buildSessionEventQueueMapperListSessionEventQueueIdentities( - yourbatis.DialectPostgres, - sessionUUID, - ) - assertMapperSQLEquals( - t, - bound, - "SELECT id, session_event_uuid FROM session_event_queue WHERE session_uuid = $1 ORDER BY id ASC FOR UPDATE", - ) - assertMapperArgumentNames(t, bound, []string{"sessionUUID"}) - }) } func TestTableMappersBuildWrites(t *testing.T) { organizationUUID := uuid.MustParse("11111111-1111-4111-8111-111111111111") workspaceUUID := uuid.MustParse("22222222-2222-4222-8222-222222222222") codeSessionUUID := uuid.MustParse("33333333-3333-4333-8333-333333333333") - sessionUUID := uuid.MustParse("44444444-4444-4444-8444-444444444444") - threadUUID := uuid.MustParse("55555555-5555-4555-8555-555555555555") - eventUUID := uuid.MustParse("66666666-6666-4666-8666-666666666666") createdAt := time.Date(2026, time.August, 2, 12, 0, 0, 0, time.UTC) row := codeSessionInboundEventInsertRow{ ExternalID: "evt_test", @@ -194,66 +115,6 @@ func TestTableMappersBuildWrites(t *testing.T) { ) assertMapperSQLContains(t, singleOutboundBound, "INSERT INTO code_session_outbound_events") assertMapperSQLContains(t, singleOutboundBound, "RETURNING uuid, external_id") - - outcomeBound := buildSessionMapperSetSessionOutcomeEvaluations( - yourbatis.DialectPostgres, - workspaceUUID, - "sesn_test", - []byte(`[{"score":1}]`), - ) - assertMapperSQLContains(t, outcomeBound, "outcome_evaluations = CAST($1 AS jsonb)") - assertMapperSQLContains(t, outcomeBound, "workspace_uuid = $2") - assertMapperArgumentNames(t, outcomeBound, []string{ - "outcomeEvaluations", - "workspaceUUID", - "sessionExternalID", - }) - - threadBound := buildSessionThreadMapperGetSessionThreadByExternalID( - yourbatis.DialectPostgres, - workspaceUUID, - "sesn_test", - "sesthr_test", - ) - assertMapperSQLContains(t, threadBound, "external_id = $3") - assertMapperArgumentNames(t, threadBound, []string{ - "workspaceUUID", - "sessionExternalID", - "threadExternalID", - }) - - eventBound := buildSessionEventMapperInsertSessionEvent( - yourbatis.DialectPostgres, - sessionEventInsertRow{ - UUID: eventUUID, - ExternalID: "event_test", - OrganizationUUID: organizationUUID, - WorkspaceUUID: workspaceUUID, - SessionUUID: sessionUUID, - SessionExternalID: "sesn_test", - ThreadUUID: threadUUID, - ThreadExternalID: "sesthr_test", - EventType: "user.message", - Payload: []byte(`{"type":"user.message"}`), - ProcessedAt: createdAt, - CreatedAt: createdAt, - }, - ) - assertMapperSQLContains(t, eventBound, "CAST($10 AS jsonb)") - assertMapperArgumentNames(t, eventBound, []string{ - "row.UUID", - "row.ExternalID", - "row.OrganizationUUID", - "row.WorkspaceUUID", - "row.SessionUUID", - "row.SessionExternalID", - "row.ThreadUUID", - "row.ThreadExternalID", - "row.EventType", - "row.Payload", - "row.ProcessedAt", - "row.CreatedAt", - }) } func assertMapperSQLContains( @@ -268,14 +129,6 @@ func assertMapperSQLContains( } } -func assertMapperSQLEquals(t *testing.T, bound yourbatis.BoundSQL, want string) { - t.Helper() - got := strings.Join(strings.Fields(bound.SQL), " ") - if got != want { - t.Fatalf("SQL = %q, want %q", got, want) - } -} - func assertMapperArgumentNames( t *testing.T, bound yourbatis.BoundSQL, diff --git a/internal/sessions/code_event_bridge.go b/internal/sessions/code_event_bridge.go index da74172b..c1d23032 100644 --- a/internal/sessions/code_event_bridge.go +++ b/internal/sessions/code_event_bridge.go @@ -13,7 +13,7 @@ import ( ) func (h *Handler) appendAndBroadcastInternal(r *http.Request, sessionID string, events []db.SessionEvent) { - created, err := h.db.AppendSessionEvents(r.Context(), workspaceUUIDFromRequest(r), sessionID, events) + created, err := h.db.AppendSessionEvents(r.Context(), workspaceUUIDFromRequest(r), sessionID, events, nil) if err != nil { h.logger.ErrorContext(r.Context(), "append internal session events", "session_id", sessionID, "error", err) return diff --git a/internal/sessions/service.go b/internal/sessions/service.go index 05e550d5..95c44e29 100644 --- a/internal/sessions/service.go +++ b/internal/sessions/service.go @@ -613,23 +613,9 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { if outcomesChanged { outcomeEvaluations = normalizedSession.OutcomeEvaluations } - created, delivery, err := h.db.AppendSessionEventsForDelivery( - r.Context(), - session.WorkspaceUUID, - session.ExternalID, - events, - outcomeEvaluations, - ) + created, err := h.db.AppendSessionEvents(r.Context(), session.WorkspaceUUID, session.ExternalID, events, outcomeEvaluations) if err != nil { - if errors.Is(err, db.ErrSessionStartupMessageConflict) { - httpapi.WriteError(w, r, httpapi.NewError( - http.StatusConflict, - "conflict_error", - "Only one user message can be accepted while the session starts", - )) - return - } - if errors.Is(err, db.ErrSessionArchived) { + if errors.Is(err, db.ErrInvalidState) { writeBadRequest(w, r, errors.New("archived sessions do not accept new events")) return } @@ -639,7 +625,7 @@ func (h *Handler) sendEventsRoute(w http.ResponseWriter, r *http.Request) { for _, event := range created { h.broadcast(event) } - if h.codeSessions != nil && delivery == db.SessionEventDeliveryRealtime { + if h.codeSessions != nil { if err := h.codeSessions.QueuePublicSessionEvents(r.Context(), session, created); err != nil { h.logger.ErrorContext(r.Context(), "queue session events for code session", "session_id", session.ExternalID, "error", err) } diff --git a/tests/deployments_api_test.go b/tests/deployments_api_test.go index beb9913f..d9a0aa57 100644 --- a/tests/deployments_api_test.go +++ b/tests/deployments_api_test.go @@ -240,15 +240,15 @@ func TestDeploymentsAPI(t *testing.T) { ) }) - t.Run("success initial user messages enter startup queue in order", func(t *testing.T) { - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"deployment-startup-queue-agent"}`) + t.Run("success initial user messages replay in order", func(t *testing.T) { + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"deployment-initial-history-agent"}`) defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"deployment-startup-queue-env"}`) + env := createEnvironment(t, app, `{"name":"deployment-initial-history-env"}`) defer cleanupEnvironmentRows(t, app.db, env.ID) deployment := createDeployment(t, app, `{ "agent":`+quoteJSON(agent.ID)+`, "environment_id":`+quoteJSON(env.ID)+`, - "name":"deployment startup queue", + "name":"deployment initial history", "initial_events":[ {"type":"user.message","content":[{"type":"text","text":"deployment first"}]}, {"type":"system.message","content":[{"type":"text","text":"public only"}]}, @@ -258,14 +258,10 @@ func TestDeploymentsAPI(t *testing.T) { defer cleanupDeploymentRows(t, app, deployment.ID) run := runDeployment(t, app, deployment.ID) if run.SessionID == nil || *run.SessionID == "" { - t.Fatalf("deployment startup queue Session ID = nil: %+v", run) + t.Fatalf("deployment initial history Session ID = nil: %+v", run) } defer deleteSession(t, app, *run.SessionID) - if queued := sessionEventQueueEventIDs(t, app, *run.SessionID); len(queued) != 2 { - t.Fatalf("deployment startup queue size = %d, want 2", len(queued)) - } - codeSessionID := launchLocalCodeSession(t, app, *run.SessionID) inbound, err := app.db.ListQueuedCodeSessionInboundEvents(context.Background(), codeSessionID) if err != nil { @@ -277,9 +273,6 @@ func TestDeploymentsAPI(t *testing.T) { !strings.Contains(string(inbound[2].Payload), "deployment second") { t.Fatalf("deployment startup inbound = %#v, want initialize, first, second", inbound) } - if remaining := sessionEventQueueEventIDs(t, app, *run.SessionID); len(remaining) != 0 { - t.Fatalf("deployment startup queue after activation = %#v, want empty", remaining) - } }) t.Run("success lifecycle manual run session events and run filters", func(t *testing.T) { diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index bf8d0add..e0610fba 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -388,9 +388,6 @@ func TestEnvironmentRunnerDeliversMessageAcceptedBeforeCodeSessionCreation(t *te !bytes.Contains(queued[2].Payload, []byte(interruptEventID)) { t.Fatalf("inbound = %#v, want initialize, accepted user message, interrupt", queued) } - if remaining := sessionEventQueueEventIDs(t, app, session.ID); len(remaining) != 0 { - t.Fatalf("remaining startup session event queue = %#v, want empty", remaining) - } } func TestEnvironmentRunnerPackageProvisioning(t *testing.T) { diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 4647a464..5557d1b5 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -685,7 +685,7 @@ func TestSessionClaudeCodeTaskEventsMapToCanonicalThreads(t *testing.T) { } } -func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) { +func TestManagedAgentActivationReplaysStartupHistory(t *testing.T) { ctx := context.Background() app := newTestAppWithStore(t, nil, newFakeStore("sessions-managed-agent-activation-bucket")) defer app.close() @@ -712,14 +712,16 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) } codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) - accepted := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"must be delivered before activation"}]}]}`, defaultTestKey) - if len(accepted.Data) != 1 { - t.Fatalf("accepted session events = %#v, want one", accepted.Data) + accepted := sendSessionEvents(t, app, session.ID, `{"events":[ + {"type":"user.message","content":[{"type":"text","text":"startup message one"}]}, + {"type":"user.message","content":[{"type":"text","text":"startup message two"}]} + ]}`, defaultTestKey) + if len(accepted.Data) != 2 { + t.Fatalf("accepted session events = %#v, want two", accepted.Data) } - acceptedEventID := sessionEventStringField(t, accepted.Data[0], "id") if err := codeSessionService.ActivateManagedAgentCodeSession(ctx, codeSession); err != nil { - t.Fatalf("activate with queued session event: %v", err) + t.Fatalf("activate with startup history: %v", err) } codeSession, err = app.db.GetCodeSession(ctx, codeSessionID) if err != nil { @@ -728,36 +730,26 @@ func TestManagedAgentActivationAtomicallyDeliversSessionEventQueue(t *testing.T) if codeSession.Status != "active" { t.Fatalf("status after activation = %q, want active", codeSession.Status) } - if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { - t.Fatalf("Session event queue after activation = %#v, want empty", queued) - } inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) if err != nil { t.Fatalf("list inbound after activation: %v", err) } - if len(inbound) != 2 || !bytes.Contains(inbound[1].Payload, []byte(acceptedEventID)) { - t.Fatalf("inbound after activation = %#v, want initialize and accepted user message", inbound) + if len(inbound) != 3 || + !bytes.Contains(inbound[1].Payload, []byte("startup message one")) || + !bytes.Contains(inbound[2].Payload, []byte("startup message two")) { + t.Fatalf("inbound after activation = %#v, want initialize and both startup messages", inbound) } - // active 后只实时投当前 batch,不再写入 startup queue。 - sent := sendSessionEvents(t, app, session.ID, `{"events":[ - {"type":"user.message","content":[{"type":"text","text":"post-cutover batch one"}]}, - {"type":"user.message","content":[{"type":"text","text":"post-cutover batch two"}]} - ]}`, defaultTestKey) - if len(sent.Data) != 2 { - t.Fatalf("post-cutover events = %#v, want two", sent.Data) - } - if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { - t.Fatalf("post-cutover startup queue = %#v, want empty", queued) + sent := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"post-cutover message"}]}]}`, defaultTestKey) + if len(sent.Data) != 1 { + t.Fatalf("post-cutover events = %#v, want one", sent.Data) } inbound, err = app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) if err != nil { t.Fatalf("list post-cutover inbound: %v", err) } - if len(inbound) != 4 || - !bytes.Contains(inbound[2].Payload, []byte("post-cutover batch one")) || - !bytes.Contains(inbound[3].Payload, []byte("post-cutover batch two")) { - t.Fatalf("post-cutover inbound = %#v, want realtime user batch after startup message", inbound) + if len(inbound) != 4 || !bytes.Contains(inbound[3].Payload, []byte("post-cutover message")) { + t.Fatalf("post-cutover inbound = %#v, want realtime message after startup history", inbound) } } @@ -805,7 +797,7 @@ func TestManagedAgentActivationPreservesLargeHistoryOrder(t *testing.T) { CreatedAt: createdAt, }) } - if _, err := app.db.AppendSessionEvents(ctx, session.WorkspaceUUID, session.ExternalID, events); err != nil { + if _, err := app.db.AppendSessionEvents(ctx, session.WorkspaceUUID, session.ExternalID, events, nil); err != nil { t.Fatalf("append large history: %v", err) } if err := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil).ActivateManagedAgentCodeSession(ctx, codeSession); err != nil { @@ -826,14 +818,14 @@ func TestManagedAgentActivationPreservesLargeHistoryOrder(t *testing.T) { } } -func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing.T) { +func TestManagedAgentActivationRollsBackOnHistoryConversionFailure(t *testing.T) { ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-event-queue-rollback-bucket")) + app := newTestAppWithStore(t, nil, newFakeStore("sessions-history-activation-rollback-bucket")) defer app.close() - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-event-queue-rollback-agent"}`) + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-history-activation-rollback-agent"}`) defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-event-queue-rollback-env"}`) + env := createEnvironment(t, app, `{"name":"sessions-history-activation-rollback-env"}`) defer cleanupEnvironmentRows(t, app.db, env.ID) sessionResponse := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) defer deleteSession(t, app, sessionResponse.ID) @@ -846,7 +838,7 @@ func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing `, codeSessionID); err != nil { t.Fatalf("set rollback code session initializing: %v", err) } - sendSessionEvents(t, app, sessionResponse.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"queue row must survive"}]}]}`, defaultTestKey) + sendSessionEvents(t, app, sessionResponse.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"history must remain durable"}]}]}`, defaultTestKey) session, err := app.db.GetSession(ctx, getDefaultDBIDs(t, app.db).WorkspaceUUID, sessionResponse.ID) if err != nil { t.Fatalf("load rollback Session: %v", err) @@ -863,7 +855,7 @@ func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing Payload: json.RawMessage(`[]`), ProcessedAt: invalidAt, CreatedAt: invalidAt, - }}); err != nil { + }}, nil); err != nil { t.Fatalf("append invalid forwardable history event: %v", err) } codeSessionService := codesessions.NewServiceWithCredentials(app.db, app.credentials, nil) @@ -874,9 +866,6 @@ func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing if err := codeSessionService.ActivateManagedAgentCodeSession(ctx, codeSession); err == nil { t.Fatal("activation with invalid forwardable history succeeded") } - if queued := sessionEventQueueEventIDs(t, app, sessionResponse.ID); len(queued) != 1 { - t.Fatalf("queue after rolled-back inbound = %#v, want one row", queued) - } after, listErr := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) if listErr != nil || len(after) != 1 { t.Fatalf("rollback inbound after delivery = (%#v, %v), want unchanged initialize", after, listErr) @@ -890,171 +879,6 @@ func TestSessionEventQueueDeliveryRollsBackOnHistoryConversionFailure(t *testing } } -func TestSessionStartupRejectsSecondUserMessage(t *testing.T) { - ctx := context.Background() - app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-conflict-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-conflict-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-startup-message-conflict-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, session.ID) - - first := sendSessionEvents(t, app, session.ID, `{"events":[{"type":"user.message","content":[{"type":"text","text":"first startup message"}]}]}`, defaultTestKey) - if len(first.Data) != 1 { - t.Fatalf("first startup response = %#v, want one event", first.Data) - } - firstID := sessionEventStringField(t, first.Data[0], "id") - - second := doSessionRequest( - t, - app, - http.MethodPost, - "/v1/sessions/"+session.ID+"/events?beta=true", - strings.NewReader(`{"events":[{"type":"user.message","content":[{"type":"text","text":"second startup message"}]}]}`), - defaultTestKey, - true, - ) - assertError(t, second, http.StatusConflict, "conflict_error") - - publicEvents := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey) - if len(publicEvents.Data) != 1 || !eventPageContains(publicEvents, firstID) { - t.Fatalf("public events after conflict = %#v, want only %s", publicEvents.Data, firstID) - } - queuedEventIDs := sessionEventQueueEventIDs(t, app, session.ID) - if !reflect.DeepEqual(queuedEventIDs, []string{firstID}) { - t.Fatalf("startup queue event IDs = %#v, want [%s]", queuedEventIDs, firstID) - } - - codeSessionID := launchLocalCodeSession(t, app, session.ID) - inbound, err := app.db.ListQueuedCodeSessionInboundEvents(ctx, codeSessionID) - if err != nil { - t.Fatalf("list startup conflict inbound events: %v", err) - } - if len(inbound) != 2 || - inbound[0].EventSubtype != "initialize" || - !bytes.Contains(inbound[1].Payload, []byte("first startup message")) || - bytes.Contains(inbound[1].Payload, []byte("second startup message")) { - t.Fatalf("inbound after startup conflict = %#v, want initialize and first message", inbound) - } - if remaining := sessionEventQueueEventIDs(t, app, session.ID); len(remaining) != 0 { - t.Fatalf("startup queue after activation = %#v, want empty", remaining) - } -} - -func TestSessionStartupRejectedBatchHasNoSideEffects(t *testing.T) { - app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-outcome-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-outcome-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-startup-message-outcome-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, session.ID) - - response := doSessionRequest( - t, - app, - http.MethodPost, - "/v1/sessions/"+session.ID+"/events?beta=true", - strings.NewReader(`{"events":[ - {"type":"user.define_outcome","description":"must not persist","rubric":{"type":"text","text":"must pass"}}, - {"type":"user.message","content":[{"type":"text","text":"reject the whole batch"}]} - ]}`), - defaultTestKey, - true, - ) - assertError(t, response, http.StatusConflict, "conflict_error") - - retrieved := retrieveSession(t, app, session.ID, defaultTestKey) - if string(retrieved.OutcomeEvaluations) != "[]" { - t.Fatalf("outcomes after rejected startup batch = %s, want []", retrieved.OutcomeEvaluations) - } - if events := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey); len(events.Data) != 0 { - t.Fatalf("startup outcome batch public events = %#v, want empty", events.Data) - } - if queued := sessionEventQueueEventIDs(t, app, session.ID); len(queued) != 0 { - t.Fatalf("startup outcome batch queue = %#v, want empty", queued) - } -} - -func TestSessionStartupSerializesConcurrentUserMessages(t *testing.T) { - app := newTestAppWithStore(t, nil, newFakeStore("sessions-startup-message-concurrency-bucket")) - defer app.close() - - agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-startup-message-concurrency-agent"}`) - defer cleanupAgentRows(t, app.db, agent.ID) - env := createEnvironment(t, app, `{"name":"sessions-startup-message-concurrency-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) - defer deleteSession(t, app, session.ID) - - type sendResult struct { - status int - body []byte - err error - } - start := make(chan struct{}) - results := make(chan sendResult, 2) - for _, prompt := range []string{"concurrent startup message one", "concurrent startup message two"} { - request, err := http.NewRequest( - http.MethodPost, - app.baseURL+"/v1/sessions/"+session.ID+"/events?beta=true", - strings.NewReader(`{"events":[{"type":"user.message","content":[{"type":"text","text":`+quoteJSON(prompt)+`}]}]}`), - ) - if err != nil { - t.Fatalf("create concurrent startup request: %v", err) - } - request.Header.Set("X-Api-Key", defaultTestKey) - request.Header.Set("anthropic-version", "2023-06-01") - request.Header.Set("anthropic-beta", "managed-agents-2026-04-01") - request.Header.Set("Content-Type", "application/json") - go func() { - <-start - response, err := app.client.Do(request) - if err != nil { - results <- sendResult{err: err} - return - } - defer response.Body.Close() - body, readErr := io.ReadAll(response.Body) - results <- sendResult{status: response.StatusCode, body: body, err: readErr} - }() - } - close(start) - - okCount := 0 - conflictCount := 0 - for range 2 { - result := <-results - if result.err != nil { - t.Fatalf("send concurrent startup message: %v", result.err) - } - switch result.status { - case http.StatusOK: - okCount++ - case http.StatusConflict: - conflictCount++ - default: - t.Fatalf("concurrent startup status = %d, want 200 or 409: %s", result.status, result.body) - } - } - if okCount != 1 || conflictCount != 1 { - t.Fatalf("concurrent startup results = %d success, %d conflict; want 1/1", okCount, conflictCount) - } - publicEvents := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey) - queuedEventIDs := sessionEventQueueEventIDs(t, app, session.ID) - if len(publicEvents.Data) != 1 || len(queuedEventIDs) != 1 { - t.Fatalf("concurrent startup persisted public/queue = %d/%d, want 1/1", len(publicEvents.Data), len(queuedEventIDs)) - } - if eventID := sessionEventStringField(t, publicEvents.Data[0], "id"); eventID != queuedEventIDs[0] { - t.Fatalf("concurrent startup public event = %s, queue event = %s", eventID, queuedEventIDs[0]) - } -} - func TestSessionClaudeCodeSubagentInternalEventsPublishToChildThread(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("sessions-claude-code-subagent-internal-bucket")) defer app.close() @@ -1684,7 +1508,7 @@ func TestSessionEventsListHidesLegacyEnvManagerLog(t *testing.T) { ProcessedAt: now.Add(time.Second), CreatedAt: now.Add(time.Second), }, - }); err != nil { + }, nil); err != nil { t.Fatalf("append legacy env manager log: %v", err) } @@ -3669,6 +3493,40 @@ func TestCodeSessionWorkerStreamReplaysUnprocessedEventsForNewEpoch(t *testing.T } } +func TestSessionEventRejectedBatchHasNoSideEffects(t *testing.T) { + app := newTestAppWithStore(t, nil, newFakeStore("sessions-events-atomicity-bucket")) + defer app.close() + + agent := createAgent(t, app, `{"model":"claude-opus-4-6","name":"sessions-events-atomicity-agent"}`) + defer cleanupAgentRows(t, app.db, agent.ID) + env := createEnvironment(t, app, `{"name":"sessions-events-atomicity-env"}`) + defer cleanupEnvironmentRows(t, app.db, env.ID) + session := createSession(t, app, `{"agent":`+quoteJSON(agent.ID)+`,"environment_id":`+quoteJSON(env.ID)+`}`) + defer deleteSession(t, app, session.ID) + + response := doSessionRequest( + t, + app, + http.MethodPost, + "/v1/sessions/"+session.ID+"/events?beta=true", + strings.NewReader(`{"events":[ + {"type":"user.define_outcome","description":"must not persist","rubric":{"type":"text","text":"must pass"}}, + {"type":"user.message","content":[]} + ]}`), + defaultTestKey, + true, + ) + assertError(t, response, http.StatusBadRequest, "invalid_request_error") + + retrieved := retrieveSession(t, app, session.ID, defaultTestKey) + if string(retrieved.OutcomeEvaluations) != "[]" { + t.Fatalf("outcomes after rejected batch = %s, want []", retrieved.OutcomeEvaluations) + } + if events := listSessionEvents(t, app, session.ID, "order=asc&limit=100", defaultTestKey); len(events.Data) != 0 { + t.Fatalf("rejected batch public events = %#v, want empty", events.Data) + } +} + func TestSessionEventInputValidation(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("sessions-events-validation-bucket")) defer app.close() @@ -3692,6 +3550,10 @@ func TestSessionEventInputValidation(t *testing.T) { if len(valid.Data) != 2 || !bytes.Contains(valid.Data[0], []byte(`"type":"user.custom_tool_result"`)) || !bytes.Contains(valid.Data[1], []byte(`"type":"user.define_outcome"`)) { t.Fatalf("unexpected valid events response: %+v", valid) } + retrieved := retrieveSession(t, app, session.ID, defaultTestKey) + if !bytes.Contains(retrieved.OutcomeEvaluations, []byte(`"max_iterations":2`)) { + t.Fatalf("outcomes after accepted batch = %s, want committed evaluation", retrieved.OutcomeEvaluations) + } } func TestSessionWebhooks(t *testing.T) { @@ -3806,7 +3668,6 @@ func TestSessionsSchemaHasNoForeignKeys(t *testing.T) { and ns.oid = current_schema()::regnamespace and cls.relname in ( 'sessions', 'session_threads', 'session_events', 'session_resources', - 'session_event_queue', 'code_sessions', 'code_session_inbound_events', 'code_session_outbound_events', 'code_session_internal_events' ) @@ -4011,34 +3872,6 @@ func sessionEventStringField(t *testing.T, raw json.RawMessage, field string) st return value } -func sessionEventQueueEventIDs(t *testing.T, app *testApp, sessionID string) []string { - t.Helper() - rows, err := app.db.Pool.Query(context.Background(), ` - select e.external_id - from session_event_queue q - join sessions s on s.uuid = q.session_uuid - join session_events e on e.uuid = q.session_event_uuid and e.deleted_at is null - where s.external_id = $1 - order by q.id asc - `, sessionID) - if err != nil { - t.Fatalf("list session event queue: %v", err) - } - defer rows.Close() - var eventIDs []string - for rows.Next() { - var eventID string - if err := rows.Scan(&eventID); err != nil { - t.Fatalf("scan session event queue: %v", err) - } - eventIDs = append(eventIDs, eventID) - } - if err := rows.Err(); err != nil { - t.Fatalf("iterate session event queue: %v", err) - } - return eventIDs -} - func eventPageContains(events sessionEventPageAPIResponse, needle string) bool { for _, event := range events.Data { if bytes.Contains(event, []byte(needle)) { diff --git a/tests/uuid_boundary_postgres_test.go b/tests/uuid_boundary_postgres_test.go index e51c6226..ec9dc389 100644 --- a/tests/uuid_boundary_postgres_test.go +++ b/tests/uuid_boundary_postgres_test.go @@ -561,7 +561,7 @@ func TestTypedUUIDSessionsAndRuntimePostgres(t *testing.T) { Payload: []byte(`{"typed_uuid":true}`), ProcessedAt: now, CreatedAt: now, - }}) + }}, nil) if err != nil || len(events) != 1 || events[0].ThreadUUID == nil || *events[0].ThreadUUID != thread.UUID { t.Fatalf("append Session event with inferred typed thread UUID = (%+v, %v)", events, err) } From 4d76cdb23b4b861e6d8f151935a38f7c34d08920 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Tue, 4 Aug 2026 21:57:34 +0800 Subject: [PATCH 22/23] refactor(db): clarify code-session append naming and comments Rename the locked code-session row variable to avoid confusion with public sessions, document idempotent append branches, and fix mapper XML indentation. --- .../managed_agent_code_session.go | 4 +-- internal/db/code_sessions.go | 35 ++++++++++--------- internal/db/db.go | 2 +- internal/db/session_event_mapper.xml | 2 +- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index 32cc04d0..ea56b666 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -167,11 +167,11 @@ func (s *Service) ActivateManagedAgentCodeSession( if err := tx.AppendCodeSessionInboundEvents(ctx, lockedCodeSession, inboundInputs); err != nil { return err } - statusUpdated, err := tx.ActivateCodeSession(ctx, lockedCodeSession.UUID, time.Now().UTC()) + activated, err := tx.ActivateCodeSession(ctx, lockedCodeSession.UUID, time.Now().UTC()) if err != nil { return err } - if !statusUpdated { + if !activated { return db.ErrInvalidState } return nil diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index e3d2b789..73156ab2 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -1022,25 +1022,26 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS inboundMapper := NewCodeSessionInboundEventMapper(executor) outboundMapper := NewCodeSessionOutboundEventMapper(executor) - session, found, err := codeSessionMapper.LockCodeSessionByExternalID(ctx, codeSessionExternalID) + codeSession, found, err := codeSessionMapper.LockCodeSessionByExternalID(ctx, codeSessionExternalID) if err != nil { return err } if !found { return ErrNotFound } - if input.RequiredWorkerEpoch != nil && session.CurrentWorkerEpoch != *input.RequiredWorkerEpoch { + if input.RequiredWorkerEpoch != nil && codeSession.CurrentWorkerEpoch != *input.RequiredWorkerEpoch { return ErrWorkerEpochMismatch } + // 有幂等键时先按 workspace 查是否已写入;命中则返回已有事件并标记 duplicate,避免重复插入。 if input.IdempotencyKey != "" { var existing codeSessionEventRow if direction == "outbound" { existing, found, err = outboundMapper.GetCodeSessionOutboundEventByIdempotencyKey( - ctx, session.WorkspaceUUID, input.IdempotencyKey, + ctx, codeSession.WorkspaceUUID, input.IdempotencyKey, ) } else { existing, found, err = inboundMapper.GetCodeSessionInboundEventByIdempotencyKey( - ctx, session.WorkspaceUUID, input.IdempotencyKey, + ctx, codeSession.WorkspaceUUID, input.IdempotencyKey, ) } if err != nil { @@ -1057,14 +1058,15 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS if now.IsZero() { now = time.Now().UTC() } + // outbound:写入 worker 产出事件,并推进 last_outbound_sequence_num。 if direction == "outbound" { - sequence := session.LastOutboundSequenceNum + 1 + sequence := codeSession.LastOutboundSequenceNum + 1 inserted, err := outboundMapper.InsertCodeSessionOutboundEvent(ctx, codeSessionOutboundEventInsertRow{ ExternalID: input.ExternalID, - OrganizationUUID: session.OrganizationUUID, - WorkspaceUUID: session.WorkspaceUUID, - CodeSessionUUID: session.UUID, - CodeSessionExternalID: session.ExternalID, + OrganizationUUID: codeSession.OrganizationUUID, + WorkspaceUUID: codeSession.WorkspaceUUID, + CodeSessionUUID: codeSession.UUID, + CodeSessionExternalID: codeSession.ExternalID, SequenceNum: sequence, EventType: input.EventType, EventSubtype: input.EventSubtype, @@ -1080,7 +1082,7 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS if err != nil { return err } - updated, err := codeSessionMapper.UpdateCodeSessionOutboundSequence(ctx, session.UUID, sequence, now) + updated, err := codeSessionMapper.UpdateCodeSessionOutboundSequence(ctx, codeSession.UUID, sequence, now) if err != nil { return err } @@ -1091,17 +1093,18 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS return nil } + // inbound:写入投递给 worker 的事件(默认 delivery_status=queued),并推进 last_inbound_sequence_num。 deliveryStatus := input.DeliveryStatus if deliveryStatus == "" { deliveryStatus = "queued" } - sequence := session.LastInboundSequenceNum + 1 + sequence := codeSession.LastInboundSequenceNum + 1 inserted, err := inboundMapper.InsertCodeSessionInboundEvent(ctx, codeSessionInboundEventInsertRow{ ExternalID: input.ExternalID, - OrganizationUUID: session.OrganizationUUID, - WorkspaceUUID: session.WorkspaceUUID, - CodeSessionUUID: session.UUID, - CodeSessionExternalID: session.ExternalID, + OrganizationUUID: codeSession.OrganizationUUID, + WorkspaceUUID: codeSession.WorkspaceUUID, + CodeSessionUUID: codeSession.UUID, + CodeSessionExternalID: codeSession.ExternalID, SequenceNum: sequence, EventType: input.EventType, EventSubtype: input.EventSubtype, @@ -1117,7 +1120,7 @@ func (d *DB) appendCodeSessionEvent(ctx context.Context, direction string, codeS if err != nil { return err } - updated, err := codeSessionMapper.UpdateCodeSessionInboundSequence(ctx, session.UUID, sequence, now) + updated, err := codeSessionMapper.UpdateCodeSessionInboundSequence(ctx, codeSession.UUID, sequence, now) if err != nil { return err } diff --git a/internal/db/db.go b/internal/db/db.go index 9da27bce..9191031b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -15,12 +15,12 @@ import ( "github.com/superduck-ai/open-managed-agents/internal/config" "github.com/superduck-ai/open-managed-agents/internal/logging" "github.com/superduck-ai/open-managed-agents/internal/platform" + "github.com/superduck-ai/yourbatis" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" - "github.com/superduck-ai/yourbatis" ) var ( diff --git a/internal/db/session_event_mapper.xml b/internal/db/session_event_mapper.xml index 5e26a3c7..a25ff569 100644 --- a/internal/db/session_event_mapper.xml +++ b/internal/db/session_event_mapper.xml @@ -20,7 +20,7 @@ deleted_at - SELECT FROM session_events From 2b1bdeef78ed76d7e915754e5fb47c838fa0f4b7 Mon Sep 17 00:00:00 2001 From: xgxgx Date: Tue, 4 Aug 2026 22:49:02 +0800 Subject: [PATCH 23/23] fix(sessions): harden activation replay --- .../managed_agent_code_session.go | 6 ++- internal/db/code_session_mapper.go | 6 +-- internal/db/code_session_mapper.xml | 12 +----- internal/db/code_sessions.go | 38 +++++++++++-------- internal/db/yourbatis_mappers_test.go | 37 ++++++++++++++++++ 5 files changed, 68 insertions(+), 31 deletions(-) diff --git a/internal/codesessions/managed_agent_code_session.go b/internal/codesessions/managed_agent_code_session.go index ea56b666..e9b6a3d0 100644 --- a/internal/codesessions/managed_agent_code_session.go +++ b/internal/codesessions/managed_agent_code_session.go @@ -145,7 +145,11 @@ func (s *Service) ActivateManagedAgentCodeSession( return err } // lock code_session by code session id - lockedCodeSession, err := tx.LockInitializingCodeSession(ctx, codeSession.UUID) + lockedCodeSession, err := tx.LockInitializingCodeSession( + ctx, + codeSession.WorkspaceUUID, + codeSession.UUID, + ) if err != nil { return err } diff --git a/internal/db/code_session_mapper.go b/internal/db/code_session_mapper.go index 9ebfcaa9..ec3a4964 100644 --- a/internal/db/code_session_mapper.go +++ b/internal/db/code_session_mapper.go @@ -18,6 +18,7 @@ type CodeSessionMapper interface { LockInitializingCodeSession( ctx context.Context, + workspaceUUID uuid.UUID, codeSessionUUID uuid.UUID, ) (codeSessionRow, bool, error) @@ -40,9 +41,4 @@ type CodeSessionMapper interface { codeSessionUUID uuid.UUID, now time.Time, ) (int64, error) - - GetLatestCodeSessionStatus( - ctx context.Context, - sessionUUID uuid.UUID, - ) (string, bool, error) } diff --git a/internal/db/code_session_mapper.xml b/internal/db/code_session_mapper.xml index b69d2aad..b1ef9a52 100644 --- a/internal/db/code_session_mapper.xml +++ b/internal/db/code_session_mapper.xml @@ -51,7 +51,8 @@ SELECT FROM code_sessions - WHERE uuid = #{codeSessionUUID} + WHERE workspace_uuid = #{workspaceUUID} + AND uuid = #{codeSessionUUID} AND status = 'initializing' AND deleted_at IS NULL FOR UPDATE @@ -79,13 +80,4 @@ AND status = 'initializing' AND deleted_at IS NULL - - diff --git a/internal/db/code_sessions.go b/internal/db/code_sessions.go index 73156ab2..1565e429 100644 --- a/internal/db/code_sessions.go +++ b/internal/db/code_sessions.go @@ -281,13 +281,18 @@ func (d *DB) CreateCodeSession(ctx context.Context, input CreateCodeSessionInput func (tx ManagedAgentActivationTx) LockInitializingCodeSession( ctx context.Context, + workspaceUUID string, codeSessionUUID string, ) (CodeSession, error) { + parsedWorkspaceUUID, err := parseDBUUID("workspace_uuid", workspaceUUID) + if err != nil { + return CodeSession{}, err + } parsedUUID, err := parseDBUUID("code_session_uuid", codeSessionUUID) if err != nil { return CodeSession{}, err } - row, found, err := tx.codeSessionMapper.LockInitializingCodeSession(ctx, parsedUUID) + row, found, err := tx.codeSessionMapper.LockInitializingCodeSession(ctx, parsedWorkspaceUUID, parsedUUID) if err != nil { return CodeSession{}, err } @@ -379,21 +384,24 @@ func listExistingActivationInboundEvents( if err != nil { return nil, err } - rows, err := codeSessionInboundEventMapper.ListExistingActivationInboundEvents( - ctx, - organizationUUID, - workspaceUUID, - idempotencyKeys, - ) - if err != nil { - return nil, err - } - existing := make(map[string]struct{}, len(rows)) - for _, row := range rows { - if row.CodeSessionExternalID != codeSession.ExternalID { - return nil, ErrInvalidState + existing := make(map[string]struct{}, len(idempotencyKeys)) + for start := 0; start < len(idempotencyKeys); start += managedAgentActivationInboundBatchSize { + end := min(start+managedAgentActivationInboundBatchSize, len(idempotencyKeys)) + rows, err := codeSessionInboundEventMapper.ListExistingActivationInboundEvents( + ctx, + organizationUUID, + workspaceUUID, + idempotencyKeys[start:end], + ) + if err != nil { + return nil, err + } + for _, row := range rows { + if row.CodeSessionExternalID != codeSession.ExternalID { + return nil, ErrInvalidState + } + existing[row.IdempotencyKey] = struct{}{} } - existing[row.IdempotencyKey] = struct{}{} } return existing, nil } diff --git a/internal/db/yourbatis_mappers_test.go b/internal/db/yourbatis_mappers_test.go index 09adc7a2..d6943b49 100644 --- a/internal/db/yourbatis_mappers_test.go +++ b/internal/db/yourbatis_mappers_test.go @@ -1,6 +1,8 @@ package db import ( + "context" + "fmt" "strings" "testing" "time" @@ -12,6 +14,7 @@ import ( func TestTableMappersBuildDynamicQueries(t *testing.T) { organizationUUID := uuid.MustParse("11111111-1111-4111-8111-111111111111") workspaceUUID := uuid.MustParse("22222222-2222-4222-8222-222222222222") + codeSessionUUID := uuid.MustParse("33333333-3333-4333-8333-333333333333") t.Run("single code session event append", func(t *testing.T) { lockBound := buildCodeSessionMapperLockCodeSessionByExternalID( @@ -38,6 +41,15 @@ func TestTableMappersBuildDynamicQueries(t *testing.T) { assertMapperSQLContains(t, outboundBound, "workspace_uuid = $1 AND idempotency_key = $2 AND deleted_at IS NULL") }) + t.Run("activation code session lock", func(t *testing.T) { + bound := buildCodeSessionMapperLockInitializingCodeSession( + yourbatis.DialectPostgres, + workspaceUUID, + codeSessionUUID, + ) + assertMapperSQLContains(t, bound, "WHERE workspace_uuid = $1 AND uuid = $2") + }) + t.Run("idempotency keys", func(t *testing.T) { bound := buildCodeSessionInboundEventMapperListExistingActivationInboundEvents( yourbatis.DialectPostgres, @@ -117,6 +129,31 @@ func TestTableMappersBuildWrites(t *testing.T) { assertMapperSQLContains(t, singleOutboundBound, "RETURNING uuid, external_id") } +func TestListExistingActivationInboundEventsBatchesLookup(t *testing.T) { + executor := newMapperTestExecutor(t, mapperTestResponse{}) + inputs := make([]AppendCodeSessionEventInput, managedAgentActivationInboundBatchSize+1) + for index := range inputs { + inputs[index].IdempotencyKey = fmt.Sprintf("idem-%d", index) + } + + _, err := listExistingActivationInboundEvents( + context.Background(), + NewCodeSessionInboundEventMapper(executor), + CodeSession{ + ExternalID: "cse_test", + OrganizationUUID: "11111111-1111-4111-8111-111111111111", + WorkspaceUUID: "22222222-2222-4222-8222-222222222222", + }, + inputs, + ) + if err != nil { + t.Fatalf("list existing activation inbound events: %v", err) + } + if executor.queryCallCount != 2 { + t.Fatalf("activation idempotency lookup calls = %d, want 2", executor.queryCallCount) + } +} + func assertMapperSQLContains( t *testing.T, bound yourbatis.BoundSQL,