CreateStep inserts a new debug step and emits a step update event. It returns errRunFinalized if the parent run has already finished, or errRunNotFound if the run_id/chat_id pair does not match an existing run. The finalization guard is enforced atomically by the INSERT's CTE, which issues an UPDATE
( ctx context.Context, params CreateStepParams, )
| 360 | // row lock). This prevents concurrent FinalizeStale from setting |
| 361 | // finished_at between the check and the INSERT. |
| 362 | func (s *Service) CreateStep( |
| 363 | ctx context.Context, |
| 364 | params CreateStepParams, |
| 365 | ) (database.ChatDebugStep, error) { |
| 366 | now := s.clock.Now() |
| 367 | insert := database.InsertChatDebugStepParams{ |
| 368 | RunID: params.RunID, |
| 369 | StepNumber: params.StepNumber, |
| 370 | Operation: string(params.Operation), |
| 371 | Status: string(params.Status), |
| 372 | HistoryTipMessageID: nullInt64(params.HistoryTipMessageID), |
| 373 | AssistantMessageID: sql.NullInt64{}, |
| 374 | NormalizedRequest: s.nullJSON(ctx, params.NormalizedRequest), |
| 375 | NormalizedResponse: pqtype.NullRawMessage{}, |
| 376 | Usage: pqtype.NullRawMessage{}, |
| 377 | Attempts: pqtype.NullRawMessage{}, |
| 378 | Error: pqtype.NullRawMessage{}, |
| 379 | Metadata: pqtype.NullRawMessage{}, |
| 380 | StartedAt: sql.NullTime{Time: now, Valid: true}, |
| 381 | UpdatedAt: sql.NullTime{Time: now, Valid: true}, |
| 382 | FinishedAt: sql.NullTime{}, |
| 383 | ChatID: params.ChatID, |
| 384 | } |
| 385 | |
| 386 | // Cap retry attempts to prevent infinite loops under |
| 387 | // pathological concurrency. Each iteration performs two DB |
| 388 | // round-trips (insert + list), so 10 retries is generous. |
| 389 | const maxCreateStepRetries = 10 |
| 390 | |
| 391 | for range maxCreateStepRetries { |
| 392 | if err := ctx.Err(); err != nil { |
| 393 | return database.ChatDebugStep{}, err |
| 394 | } |
| 395 | |
| 396 | step, err := s.db.InsertChatDebugStep(chatdContext(ctx), insert) |
| 397 | if err == nil { |
| 398 | // The INSERT CTE atomically bumps the parent run's |
| 399 | // updated_at, so no separate touch call is needed. |
| 400 | s.publishEvent(ctx, step.ChatID, EventKindStepUpdate, step.RunID, step.ID) |
| 401 | return step, nil |
| 402 | } |
| 403 | // The INSERT's locked_run CTE filters on id, chat_id, and |
| 404 | // finished_at IS NULL, so sql.ErrNoRows can mean "run not |
| 405 | // found", "chat_id mismatch", or "already finalized." Look |
| 406 | // the run up to disambiguate instead of conflating |
| 407 | // caller-side data bugs with the legitimate terminal case. |
| 408 | if errors.Is(err, sql.ErrNoRows) { |
| 409 | return database.ChatDebugStep{}, s.classifyMissingRun(ctx, params) |
| 410 | } |
| 411 | if !database.IsUniqueViolation(err, database.UniqueIndexChatDebugStepsRunStep) { |
| 412 | return database.ChatDebugStep{}, err |
| 413 | } |
| 414 | |
| 415 | steps, listErr := s.db.GetChatDebugStepsByRunID(chatdContext(ctx), params.RunID) |
| 416 | if listErr != nil { |
| 417 | return database.ChatDebugStep{}, listErr |
| 418 | } |
| 419 | nextStepNumber := insert.StepNumber + 1 |