Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
813 lines
31 KiB
Go
813 lines
31 KiB
Go
package storesqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RecordTurnTransition upserts one turn phase transition and keeps the
|
|
// owning session's active_turn_id reference in sync: a live phase points the
|
|
// session at this turn, a settled phase clears the pointer (only if it still
|
|
// points at this turn). A turn that is already settled is terminal; later
|
|
// lifecycle transitions are rejected so cancel races and replays stay
|
|
// idempotent. Capability provenance is independent metadata and may still be
|
|
// merged when its report arrives after the lifecycle advanced. A phase-less
|
|
// transition is accepted only for this merge-only form.
|
|
func (s *Store) RecordTurnTransition(ctx context.Context, transition TurnTransition) (Turn, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return Turn{}, false, errors.New("workspace database is not initialized")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return Turn{}, false, fmt.Errorf("begin workspace agent turn transition: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
turn, accepted, err := s.recordTurnTransitionTx(ctx, tx, transition, unixMs(time.Now().UTC()))
|
|
if err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
mutations := []TransactionMutation{}
|
|
if accepted {
|
|
turnMutation := transactionMutation(
|
|
turn.WorkspaceID, turn.AgentSessionID, MutationEntityTurn,
|
|
turn.TurnID, "upsert", turn.UpdatedAtUnixMS,
|
|
)
|
|
// The persisted row may already be settled when a late capability-only
|
|
// merge is accepted. Only the incoming lifecycle fact can identify a
|
|
// terminal transition; the final row shape cannot.
|
|
if strings.TrimSpace(transition.Phase) == TurnPhaseSettled {
|
|
turnMutation = terminalTurnMutation(
|
|
turn.WorkspaceID, turn.AgentSessionID, turn.TurnID,
|
|
"upsert", turn.UpdatedAtUnixMS, false,
|
|
)
|
|
}
|
|
mutations = append(mutations, turnMutation,
|
|
transactionMutation(turn.WorkspaceID, turn.AgentSessionID, MutationEntitySession, turn.AgentSessionID, "upsert", turn.UpdatedAtUnixMS))
|
|
}
|
|
if _, err := s.commitTransaction(ctx, tx, transition.WorkspaceID, mutations); err != nil {
|
|
return Turn{}, false, fmt.Errorf("commit workspace agent turn transition: %w", err)
|
|
}
|
|
committed = true
|
|
return turn, accepted, nil
|
|
}
|
|
|
|
func (*Store) recordTurnTransitionTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
transition TurnTransition,
|
|
now int64,
|
|
) (Turn, bool, error) {
|
|
workspaceID := strings.TrimSpace(transition.WorkspaceID)
|
|
agentSessionID := strings.TrimSpace(transition.AgentSessionID)
|
|
turnID := strings.TrimSpace(transition.TurnID)
|
|
phase := strings.TrimSpace(transition.Phase)
|
|
capabilityRefs := normalizeCapabilityReferences(transition.CapabilityRefs)
|
|
if workspaceID == "" || agentSessionID == "" || turnID == "" {
|
|
return Turn{}, false, errors.New("workspace id, agent session id, and turn id are required")
|
|
}
|
|
if err := requireSessionForkSourceWritableTx(ctx, tx, workspaceID, agentSessionID); err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
metadataOnly := phase == "" && len(capabilityRefs) > 0
|
|
if !metadataOnly && !isKnownTurnPhase(phase) {
|
|
return Turn{}, false, fmt.Errorf("unknown workspace agent turn phase %q", phase)
|
|
}
|
|
if transition.Outcome != "" && !isKnownTurnOutcome(transition.Outcome) {
|
|
return Turn{}, false, fmt.Errorf("unknown workspace agent turn outcome %q", transition.Outcome)
|
|
}
|
|
if transition.Origin != "" && !isKnownTurnOrigin(transition.Origin) {
|
|
return Turn{}, false, fmt.Errorf("unknown workspace agent turn origin %q", transition.Origin)
|
|
}
|
|
if err := validateLiveTurnSlotTx(ctx, tx, workspaceID, agentSessionID, turnID, phase); err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
|
|
occurred := transition.OccurredAtUnixMS
|
|
if occurred <= 0 {
|
|
occurred = now
|
|
}
|
|
|
|
existing, hasExisting, err := getAgentTurnTx(ctx, tx, workspaceID, agentSessionID, turnID)
|
|
if err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
if metadataOnly {
|
|
if !hasExisting {
|
|
return Turn{}, false, fmt.Errorf("merge workspace agent turn capability refs: %w", sql.ErrNoRows)
|
|
}
|
|
return mergeTurnCapabilityReferencesTx(ctx, tx, existing, capabilityRefs)
|
|
}
|
|
lifecycleRejected := hasExisting && existing.Phase == TurnPhaseSettled && !existing.Backfilled
|
|
if hasExisting && !existing.Backfilled &&
|
|
(occurred < existing.UpdatedAtUnixMS || !isAllowedTurnPhaseTransition(existing.Phase, phase)) {
|
|
lifecycleRejected = true
|
|
}
|
|
if lifecycleRejected {
|
|
if len(capabilityRefs) > 0 {
|
|
stored, changed, err := mergeTurnCapabilityReferencesTx(ctx, tx, existing, capabilityRefs)
|
|
return stored, changed, err
|
|
}
|
|
// Reject stale, illegal, or terminal lifecycle mutations silently so
|
|
// replays are idempotent. Capability provenance above is independent
|
|
// metadata and may still arrive after the lifecycle advanced. Backfilled
|
|
// placeholder rows stay writable so live reports can enrich them.
|
|
return existing, false, nil
|
|
}
|
|
if err := validateLiveTurnSlotTx(ctx, tx, workspaceID, agentSessionID, turnID, phase); err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
|
|
merged := mergeTurnTransition(existing, hasExisting, transition, phase, occurred, now)
|
|
if merged.Phase == TurnPhaseSettled {
|
|
merged.FinalAssistantMessageID, err = finalAssistantMessageIDAtSettlementTx(
|
|
ctx, tx, workspaceID, agentSessionID, turnID, transition.FinalAssistantMessageID,
|
|
)
|
|
if err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
merged.FinalAssistantMessageResolved = true
|
|
}
|
|
|
|
fileChangesJSON, err := marshalNullableJSONMap(merged.FileChanges)
|
|
if err != nil {
|
|
return Turn{}, false, fmt.Errorf("encode workspace agent turn file changes: %w", err)
|
|
}
|
|
capabilityRefsJSON, err := json.Marshal(merged.CapabilityRefs)
|
|
if err != nil {
|
|
return Turn{}, false, fmt.Errorf("encode workspace agent turn capability refs: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO workspace_agent_turns (
|
|
workspace_id, agent_session_id, turn_id, capability_refs_json, phase, outcome, error_json,
|
|
file_changes_json, completed_command_json, backfilled,
|
|
started_at_unix_ms, settled_at_unix_ms, created_at_unix_ms, updated_at_unix_ms,
|
|
turn_origin, source_goal_operation_id, source_goal_revision, source_goal_repair_epoch
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(workspace_id, agent_session_id, turn_id) DO UPDATE SET
|
|
capability_refs_json = excluded.capability_refs_json,
|
|
phase = excluded.phase,
|
|
outcome = excluded.outcome,
|
|
error_json = excluded.error_json,
|
|
file_changes_json = excluded.file_changes_json,
|
|
completed_command_json = excluded.completed_command_json,
|
|
backfilled = 0,
|
|
started_at_unix_ms = excluded.started_at_unix_ms,
|
|
settled_at_unix_ms = excluded.settled_at_unix_ms,
|
|
updated_at_unix_ms = excluded.updated_at_unix_ms
|
|
`, workspaceID, agentSessionID, turnID, string(capabilityRefsJSON), merged.Phase, nullString(merged.Outcome),
|
|
encodeTurnErrorJSON(merged.ErrorMessage, merged.ErrorCode),
|
|
fileChangesJSON,
|
|
encodeCompletedCommandJSON(merged.CompletedCommandKind, merged.CompletedCommandStatus, finalAssistantWatermark{
|
|
MessageID: merged.FinalAssistantMessageID, Resolved: merged.FinalAssistantMessageResolved,
|
|
}),
|
|
merged.StartedAtUnixMS, nullInt64(merged.SettledAtUnixMS),
|
|
merged.CreatedAtUnixMS, merged.UpdatedAtUnixMS, merged.Origin,
|
|
nullString(merged.SourceGoalOperationID), nullInt64(merged.SourceGoalRevision), nullInt64WhenAbsent(merged.SourceGoalRepairEpoch, merged.SourceGoalOperationID != "")); err != nil {
|
|
return Turn{}, false, fmt.Errorf("upsert workspace agent turn: %w", err)
|
|
}
|
|
// A fresh canonical Turn is appended under the store's single-live-Turn
|
|
// invariant, so its trigger-assigned position is trustworthy. Migration
|
|
// and imported/backfilled paths establish their proof independently.
|
|
if !hasExisting && !merged.Backfilled {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_turn_sequences
|
|
SET provenance = 'verified'
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND turn_id = ?
|
|
`, workspaceID, agentSessionID, turnID); err != nil {
|
|
return Turn{}, false, fmt.Errorf("verify workspace agent turn sequence: %w", err)
|
|
}
|
|
}
|
|
if merged.Phase == TurnPhaseSettled {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_sessions
|
|
SET active_turn_id = NULL, updated_at_unix_ms = ?
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND active_turn_id = ?
|
|
`, now, workspaceID, agentSessionID, turnID); err != nil {
|
|
return Turn{}, false, fmt.Errorf("clear workspace agent session active turn: %w", err)
|
|
}
|
|
// A settled turn supersedes any interaction still pending on it.
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_interactions
|
|
SET status = ?, updated_at_unix_ms = ?
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND turn_id = ? AND status = ?
|
|
`, InteractionStatusSuperseded, now, workspaceID, agentSessionID, turnID, InteractionStatusPending); err != nil {
|
|
return Turn{}, false, fmt.Errorf("supersede workspace agent interactions on settle: %w", err)
|
|
}
|
|
} else {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_sessions
|
|
SET active_turn_id = ?, updated_at_unix_ms = ?
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND deleted_at_unix_ms = 0
|
|
AND active_turn_id IS NULL
|
|
`, turnID, now, workspaceID, agentSessionID); err != nil {
|
|
return Turn{}, false, fmt.Errorf("set workspace agent session active turn: %w", err)
|
|
}
|
|
}
|
|
if acceptedGoalExecutionTurn(merged) {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_session_goals
|
|
SET execution_pending = 0, updated_at_unix_ms = MAX(updated_at_unix_ms, ?)
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND revision = ? AND execution_pending = 1
|
|
AND EXISTS (
|
|
SELECT 1 FROM workspace_agent_goal_control_operations operation
|
|
WHERE operation.workspace_id = workspace_agent_session_goals.workspace_id
|
|
AND operation.agent_session_id = workspace_agent_session_goals.agent_session_id
|
|
AND operation.operation_id = ? AND operation.goal_revision = ? AND operation.repair_epoch = ?
|
|
)
|
|
`, occurred, workspaceID, agentSessionID, merged.SourceGoalRevision,
|
|
merged.SourceGoalOperationID, merged.SourceGoalRevision, merged.SourceGoalRepairEpoch); err != nil {
|
|
return Turn{}, false, fmt.Errorf("clear Goal execution-pending state: %w", err)
|
|
}
|
|
}
|
|
|
|
stored, ok, err := getAgentTurnTx(ctx, tx, workspaceID, agentSessionID, turnID)
|
|
if err != nil {
|
|
return Turn{}, false, err
|
|
}
|
|
if !ok {
|
|
return Turn{}, false, fmt.Errorf("read recorded workspace agent turn: %w", sql.ErrNoRows)
|
|
}
|
|
return stored, true, nil
|
|
}
|
|
|
|
func acceptedGoalExecutionTurn(turn Turn) bool {
|
|
if turn.Origin != TurnOriginGoalArm && turn.Origin != TurnOriginGoalContinuation {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(turn.SourceGoalOperationID) != "" &&
|
|
turn.SourceGoalRevision > 0 && turn.SourceGoalRepairEpoch >= 0
|
|
}
|
|
|
|
// validateLiveTurnSlotTx enforces the session-to-live-turn cardinality at the
|
|
// durable write boundary. Keeping the previous active_turn_id while still
|
|
// inserting a second live turn only hides the conflict from selectors; it
|
|
// leaves two canonical live entities behind. Returning an error is
|
|
// intentional so ReportActivityState rolls the accompanying session patch
|
|
// back in the same transaction.
|
|
func validateLiveTurnSlotTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
workspaceID string,
|
|
agentSessionID string,
|
|
turnID string,
|
|
phase string,
|
|
) error {
|
|
if phase == TurnPhaseSettled {
|
|
return nil
|
|
}
|
|
|
|
var activeTurnID sql.NullString
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT active_turn_id
|
|
FROM workspace_agent_sessions
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND deleted_at_unix_ms = 0
|
|
`, workspaceID, agentSessionID).Scan(&activeTurnID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("workspace agent turn references unknown or deleted session %q", agentSessionID)
|
|
}
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("read workspace agent session active turn: %w", err)
|
|
}
|
|
if active := strings.TrimSpace(activeTurnID.String); activeTurnID.Valid && active != "" && active != turnID {
|
|
return fmt.Errorf("workspace agent session already has live turn %q; cannot start %q", active, turnID)
|
|
}
|
|
|
|
var conflictingTurnID string
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT turn_id
|
|
FROM workspace_agent_turns
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND phase != ? AND turn_id != ?
|
|
ORDER BY updated_at_unix_ms DESC, turn_id DESC
|
|
LIMIT 1
|
|
`, workspaceID, agentSessionID, TurnPhaseSettled, turnID).Scan(&conflictingTurnID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read workspace agent session live turns: %w", err)
|
|
}
|
|
return fmt.Errorf("workspace agent session already has live turn %q; cannot start %q", conflictingTurnID, turnID)
|
|
}
|
|
|
|
func mergeTurnTransition(existing Turn, hasExisting bool, transition TurnTransition, phase string, occurred int64, now int64) Turn {
|
|
merged := existing
|
|
if !hasExisting {
|
|
merged = Turn{
|
|
WorkspaceID: strings.TrimSpace(transition.WorkspaceID),
|
|
AgentSessionID: strings.TrimSpace(transition.AgentSessionID),
|
|
TurnID: strings.TrimSpace(transition.TurnID),
|
|
CreatedAtUnixMS: now,
|
|
Origin: TurnOriginLegacyUnknown,
|
|
}
|
|
if origin := strings.TrimSpace(transition.Origin); origin != "" {
|
|
merged.Origin = origin
|
|
}
|
|
merged.SourceGoalOperationID = strings.TrimSpace(transition.SourceGoalOperationID)
|
|
merged.SourceGoalRevision = transition.SourceGoalRevision
|
|
merged.SourceGoalRepairEpoch = transition.SourceGoalRepairEpoch
|
|
}
|
|
merged.Phase = phase
|
|
merged.CapabilityRefs = normalizeCapabilityReferences(append(
|
|
append([]CapabilityReference(nil), existing.CapabilityRefs...),
|
|
transition.CapabilityRefs...,
|
|
))
|
|
merged.Backfilled = false
|
|
merged.UpdatedAtUnixMS = occurred
|
|
if transition.ErrorMessage != "" || transition.ErrorCode != "" {
|
|
merged.ErrorMessage = strings.TrimSpace(transition.ErrorMessage)
|
|
merged.ErrorCode = strings.TrimSpace(transition.ErrorCode)
|
|
}
|
|
if len(transition.FileChanges) > 0 {
|
|
merged.FileChanges = cloneJSONMap(transition.FileChanges)
|
|
}
|
|
if transition.CompletedCommandKind != "" {
|
|
merged.CompletedCommandKind = strings.TrimSpace(transition.CompletedCommandKind)
|
|
merged.CompletedCommandStatus = strings.TrimSpace(transition.CompletedCommandStatus)
|
|
}
|
|
if transition.FinalAssistantMessageID != "" {
|
|
merged.FinalAssistantMessageID = strings.TrimSpace(transition.FinalAssistantMessageID)
|
|
}
|
|
startedAt := transition.StartedAtUnixMS
|
|
if startedAt <= 0 {
|
|
startedAt = occurred
|
|
}
|
|
if merged.StartedAtUnixMS <= 0 || (startedAt > 0 && startedAt < merged.StartedAtUnixMS) {
|
|
merged.StartedAtUnixMS = startedAt
|
|
}
|
|
if phase == TurnPhaseSettled {
|
|
settledAt := transition.SettledAtUnixMS
|
|
if settledAt <= 0 {
|
|
settledAt = occurred
|
|
}
|
|
merged.SettledAtUnixMS = settledAt
|
|
merged.Outcome = strings.TrimSpace(transition.Outcome)
|
|
if merged.Outcome == "" {
|
|
merged.Outcome = TurnOutcomeCompleted
|
|
}
|
|
} else {
|
|
// Outcome only exists once the turn is settled.
|
|
merged.Outcome = ""
|
|
merged.SettledAtUnixMS = 0
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func isAllowedTurnPhaseTransition(existing string, incoming string) bool {
|
|
if existing == incoming {
|
|
return true
|
|
}
|
|
switch existing {
|
|
case TurnPhaseSubmitted:
|
|
return incoming == TurnPhaseRunning || incoming == TurnPhaseWaiting ||
|
|
incoming == TurnPhaseSettling || incoming == TurnPhaseSettled
|
|
case TurnPhaseRunning:
|
|
return incoming == TurnPhaseWaiting || incoming == TurnPhaseSettling || incoming == TurnPhaseSettled
|
|
case TurnPhaseWaiting:
|
|
return incoming == TurnPhaseRunning || incoming == TurnPhaseSettling || incoming == TurnPhaseSettled
|
|
case TurnPhaseSettling:
|
|
return incoming == TurnPhaseSettled
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Store) GetTurn(ctx context.Context, workspaceID string, agentSessionID string, turnID string) (Turn, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return Turn{}, false, errors.New("workspace database is not initialized")
|
|
}
|
|
workspaceID = strings.TrimSpace(workspaceID)
|
|
agentSessionID = strings.TrimSpace(agentSessionID)
|
|
turnID = strings.TrimSpace(turnID)
|
|
if workspaceID == "" || agentSessionID == "" || turnID == "" {
|
|
return Turn{}, false, nil
|
|
}
|
|
row := s.db.QueryRowContext(ctx, agentTurnSelectSQL+`
|
|
WHERE workspace_id = ? AND agent_session_id = ? AND turn_id = ?
|
|
`, workspaceID, agentSessionID, turnID)
|
|
turn, err := scanAgentTurn(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Turn{}, false, nil
|
|
}
|
|
return Turn{}, false, fmt.Errorf("get workspace agent turn: %w", err)
|
|
}
|
|
return turn, true, nil
|
|
}
|
|
|
|
func (s *Store) ListSessionTurns(ctx context.Context, workspaceID string, agentSessionID string) ([]Turn, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, errors.New("workspace database is not initialized")
|
|
}
|
|
workspaceID = strings.TrimSpace(workspaceID)
|
|
agentSessionID = strings.TrimSpace(agentSessionID)
|
|
if workspaceID == "" || agentSessionID == "" {
|
|
return nil, nil
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, agentTurnSelectSQL+`
|
|
WHERE workspace_id = ? AND agent_session_id = ?
|
|
ORDER BY started_at_unix_ms ASC, turn_id ASC
|
|
`, workspaceID, agentSessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list workspace agent turns: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
turns := make([]Turn, 0)
|
|
for rows.Next() {
|
|
turn, err := scanAgentTurn(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
turns = append(turns, turn)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate workspace agent turns: %w", err)
|
|
}
|
|
return turns, nil
|
|
}
|
|
|
|
// SettleStaleTurns force-settles every turn that is not settled with outcome
|
|
// interrupted, clears session active turn pointers, and supersedes pending
|
|
// interactions. It runs at daemon startup (protocol v2 rule nine): after a
|
|
// daemon restart no provider process survives, so any live turn on disk is a
|
|
// lie that must be settled by reconciliation, not guessed lazily at read
|
|
// time.
|
|
func (s *Store) SettleStaleTurns(ctx context.Context) ([]StaleTurnSettlement, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, errors.New("workspace database is not initialized")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin workspace agent stale turn settlement: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT t.workspace_id, t.agent_session_id, t.turn_id,
|
|
COALESCE(s.provider, ''), s.session_kind, COALESCE(s.parent_tool_call_id, ''),
|
|
t.started_at_unix_ms
|
|
FROM workspace_agent_turns AS t
|
|
JOIN workspace_agent_sessions AS s
|
|
ON s.workspace_id = t.workspace_id
|
|
AND s.agent_session_id = t.agent_session_id
|
|
WHERE t.phase != ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM workspace_agent_runtime_operations AS op
|
|
WHERE op.workspace_id = t.workspace_id
|
|
AND op.agent_session_id = t.agent_session_id
|
|
AND (
|
|
op.turn_id = t.turn_id
|
|
OR (
|
|
op.kind = ?
|
|
AND json_extract(op.payload_json, '$.replacementTurnId') = t.turn_id
|
|
)
|
|
)
|
|
AND op.status IN (?, ?)
|
|
)
|
|
ORDER BY t.workspace_id ASC, t.agent_session_id ASC, t.turn_id ASC
|
|
`, TurnPhaseSettled, RuntimeOperationKindEditRetry,
|
|
RuntimeOperationStatusPrepared, RuntimeOperationStatusLeased)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list stale workspace agent turns: %w", err)
|
|
}
|
|
settlements := make([]StaleTurnSettlement, 0)
|
|
for rows.Next() {
|
|
var settlement StaleTurnSettlement
|
|
var sessionKind, parentToolCallID string
|
|
if err := rows.Scan(
|
|
&settlement.WorkspaceID,
|
|
&settlement.AgentSessionID,
|
|
&settlement.TurnID,
|
|
&settlement.Provider,
|
|
&sessionKind,
|
|
&parentToolCallID,
|
|
&settlement.StartedAtUnixMS,
|
|
); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scan stale workspace agent turn: %w", err)
|
|
}
|
|
settlement.IsChildSession = strings.EqualFold(strings.TrimSpace(sessionKind), SessionKindChild) ||
|
|
strings.TrimSpace(parentToolCallID) != ""
|
|
settlements = append(settlements, settlement)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("iterate stale workspace agent turns: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
if len(settlements) == 0 {
|
|
if _, err := s.commitTransaction(ctx, tx, "", nil); err != nil {
|
|
return nil, fmt.Errorf("commit empty workspace agent stale turn settlement: %w", err)
|
|
}
|
|
committed = true
|
|
return nil, nil
|
|
}
|
|
|
|
now := unixMs(time.Now().UTC())
|
|
for index := range settlements {
|
|
settlements[index].SettledAtUnixMS = now
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_turns AS t
|
|
SET phase = ?, outcome = ?, settled_at_unix_ms = ?, updated_at_unix_ms = ?
|
|
WHERE phase != ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM workspace_agent_runtime_operations AS op
|
|
WHERE op.workspace_id = t.workspace_id
|
|
AND op.agent_session_id = t.agent_session_id
|
|
AND (
|
|
op.turn_id = t.turn_id
|
|
OR (
|
|
op.kind = ?
|
|
AND json_extract(op.payload_json, '$.replacementTurnId') = t.turn_id
|
|
)
|
|
)
|
|
AND op.status IN (?, ?)
|
|
)
|
|
`, TurnPhaseSettled, TurnOutcomeInterrupted, now, now, TurnPhaseSettled,
|
|
RuntimeOperationKindEditRetry,
|
|
RuntimeOperationStatusPrepared, RuntimeOperationStatusLeased); err != nil {
|
|
return nil, fmt.Errorf("settle stale workspace agent turns: %w", err)
|
|
}
|
|
for index := range settlements {
|
|
turn, found, err := getAgentTurnTx(
|
|
ctx,
|
|
tx,
|
|
settlements[index].WorkspaceID,
|
|
settlements[index].AgentSessionID,
|
|
settlements[index].TurnID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read settled stale workspace agent turn: %w", err)
|
|
}
|
|
if !found {
|
|
return nil, errors.New("settled stale workspace agent turn disappeared")
|
|
}
|
|
settlements[index].Turn = turn
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_sessions AS s
|
|
SET active_turn_id = NULL, updated_at_unix_ms = ?
|
|
WHERE active_turn_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM workspace_agent_runtime_operations AS op
|
|
WHERE op.workspace_id = s.workspace_id
|
|
AND op.agent_session_id = s.agent_session_id
|
|
AND (
|
|
op.turn_id = s.active_turn_id
|
|
OR (
|
|
op.kind = ?
|
|
AND json_extract(op.payload_json, '$.replacementTurnId') = s.active_turn_id
|
|
)
|
|
)
|
|
AND op.status IN (?, ?)
|
|
)
|
|
`, now, RuntimeOperationKindEditRetry,
|
|
RuntimeOperationStatusPrepared, RuntimeOperationStatusLeased); err != nil {
|
|
return nil, fmt.Errorf("clear stale workspace agent session active turns: %w", err)
|
|
}
|
|
pendingInteractions, err := listStalePendingInteractionsTx(ctx, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE workspace_agent_interactions AS i
|
|
SET status = ?, updated_at_unix_ms = ?
|
|
WHERE status = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM workspace_agent_runtime_operations AS op
|
|
WHERE op.workspace_id = i.workspace_id
|
|
AND op.agent_session_id = i.agent_session_id
|
|
AND (
|
|
op.turn_id = i.turn_id
|
|
OR (
|
|
op.kind = ?
|
|
AND json_extract(op.payload_json, '$.replacementTurnId') = i.turn_id
|
|
)
|
|
)
|
|
AND op.status IN (?, ?)
|
|
)
|
|
`, InteractionStatusSuperseded, now, InteractionStatusPending,
|
|
RuntimeOperationKindEditRetry,
|
|
RuntimeOperationStatusPrepared, RuntimeOperationStatusLeased); err != nil {
|
|
return nil, fmt.Errorf("supersede stale workspace agent interactions: %w", err)
|
|
}
|
|
canceledMessages, err := s.cancelStaleTurnToolMessagesTx(ctx, tx, settlements, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
notifiedSessions := make(map[string]Message, len(settlements))
|
|
for _, settlement := range settlements {
|
|
key := settlement.WorkspaceID + "\x00" + settlement.AgentSessionID
|
|
if _, exists := notifiedSessions[key]; exists {
|
|
continue
|
|
}
|
|
message, err := insertStaleTurnSystemMessageTx(ctx, tx, settlement, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
notifiedSessions[key] = message
|
|
}
|
|
|
|
mutations := make([]TransactionMutation, 0, len(settlements)*3+len(pendingInteractions)+len(canceledMessages))
|
|
for _, settlement := range settlements {
|
|
mutations = append(mutations,
|
|
terminalTurnMutation(settlement.WorkspaceID, settlement.AgentSessionID, settlement.TurnID, "settle", now, true),
|
|
transactionMutation(settlement.WorkspaceID, settlement.AgentSessionID, MutationEntitySession, settlement.AgentSessionID, "upsert", now),
|
|
)
|
|
}
|
|
for _, interaction := range pendingInteractions {
|
|
mutations = append(mutations, transactionMutation(
|
|
interaction.WorkspaceID, interaction.AgentSessionID, MutationEntityInteraction,
|
|
interactionMutationEntityID(interaction.TurnID, interaction.RequestID), "supersede", now,
|
|
))
|
|
}
|
|
for _, canceled := range canceledMessages {
|
|
mutations = append(mutations, transactionMutation(
|
|
canceled.workspaceID, canceled.message.AgentSessionID, MutationEntityMessage,
|
|
canceled.message.MessageID, "upsert", int64(canceled.message.Version),
|
|
))
|
|
}
|
|
for key, message := range notifiedSessions {
|
|
parts := strings.SplitN(key, "\x00", 2)
|
|
mutations = append(mutations, transactionMutation(
|
|
parts[0], message.AgentSessionID, MutationEntityMessage,
|
|
message.MessageID, "insert", int64(message.Version),
|
|
))
|
|
}
|
|
delta, err := s.commitTransaction(ctx, tx, "", mutations)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("commit workspace agent stale turn settlement: %w", err)
|
|
}
|
|
committed = true
|
|
for index := range settlements {
|
|
settlements[index].TransactionID = delta.TransactionID
|
|
settlements[index].CommitDelta = delta
|
|
}
|
|
return settlements, nil
|
|
}
|
|
|
|
func listStalePendingInteractionsTx(ctx context.Context, tx *sql.Tx) ([]Interaction, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT i.workspace_id, i.agent_session_id, i.turn_id, i.request_id
|
|
FROM workspace_agent_interactions AS i
|
|
WHERE i.status = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM workspace_agent_runtime_operations AS op
|
|
WHERE op.workspace_id = i.workspace_id
|
|
AND op.agent_session_id = i.agent_session_id
|
|
AND (
|
|
op.turn_id = i.turn_id
|
|
OR (
|
|
op.kind = ?
|
|
AND json_extract(op.payload_json, '$.replacementTurnId') = i.turn_id
|
|
)
|
|
)
|
|
AND op.status IN (?, ?)
|
|
)
|
|
ORDER BY i.workspace_id, i.agent_session_id, i.turn_id, i.request_id
|
|
`, InteractionStatusPending, RuntimeOperationKindEditRetry,
|
|
RuntimeOperationStatusPrepared, RuntimeOperationStatusLeased)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list stale workspace agent interactions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
interactions := make([]Interaction, 0)
|
|
for rows.Next() {
|
|
var interaction Interaction
|
|
if err := rows.Scan(&interaction.WorkspaceID, &interaction.AgentSessionID, &interaction.TurnID, &interaction.RequestID); err != nil {
|
|
return nil, fmt.Errorf("scan stale workspace agent interaction: %w", err)
|
|
}
|
|
interactions = append(interactions, interaction)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate stale workspace agent interactions: %w", err)
|
|
}
|
|
return interactions, nil
|
|
}
|
|
|
|
func (*Store) upsertInteractionTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
upsert InteractionUpsert,
|
|
now int64,
|
|
) (Interaction, InteractionTransitionResult, error) {
|
|
workspaceID := strings.TrimSpace(upsert.WorkspaceID)
|
|
agentSessionID := strings.TrimSpace(upsert.AgentSessionID)
|
|
requestID := strings.TrimSpace(upsert.RequestID)
|
|
turnID := strings.TrimSpace(upsert.TurnID)
|
|
kind := strings.TrimSpace(upsert.Kind)
|
|
status := strings.TrimSpace(upsert.Status)
|
|
if workspaceID == "" || agentSessionID == "" || requestID == "" || turnID == "" {
|
|
return Interaction{}, InteractionTransitionConflict, errors.New("workspace id, agent session id, request id, and turn id are required")
|
|
}
|
|
if err := requireSessionForkSourceWritableTx(ctx, tx, workspaceID, agentSessionID); err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, err
|
|
}
|
|
if !isKnownInteractionKind(kind) {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("unknown workspace agent interaction kind %q", kind)
|
|
}
|
|
if !isKnownInteractionStatus(status) {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("unknown workspace agent interaction status %q", status)
|
|
}
|
|
|
|
occurred := upsert.OccurredAtUnixMS
|
|
if occurred <= 0 {
|
|
occurred = now
|
|
}
|
|
|
|
existing, hasExisting, err := getAgentInteractionTx(ctx, tx, workspaceID, agentSessionID, turnID, requestID)
|
|
if err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, err
|
|
}
|
|
if hasExisting {
|
|
if !interactionImmutableIdentityEqual(existing, upsert) {
|
|
return existing, InteractionTransitionConflict, nil
|
|
}
|
|
if existing.Status != InteractionStatusPending || status == existing.Status {
|
|
return existing, InteractionTransitionAlreadyApplied, nil
|
|
}
|
|
if status == InteractionStatusPending {
|
|
return existing, InteractionTransitionConflict, nil
|
|
}
|
|
}
|
|
|
|
ownerTurn, hasTurn, err := getAgentTurnTx(ctx, tx, workspaceID, agentSessionID, turnID)
|
|
if err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, err
|
|
}
|
|
if !hasTurn {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("workspace agent interaction references unknown turn %q", turnID)
|
|
}
|
|
if ownerTurn.Phase == TurnPhaseSettled && status == InteractionStatusPending {
|
|
// A settled turn cannot acquire new actionable work. Treat a late pending
|
|
// provider report as an idempotent stale transition; terminal reports may
|
|
// still be recorded for replay and reconciliation evidence.
|
|
return Interaction{}, InteractionTransitionAlreadyApplied, nil
|
|
}
|
|
|
|
inputJSON, err := marshalJSONMap(upsert.Input)
|
|
if err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("encode workspace agent interaction input: %w", err)
|
|
}
|
|
outputJSON, err := marshalJSONMap(upsert.Output)
|
|
if err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("encode workspace agent interaction output: %w", err)
|
|
}
|
|
metadataJSON, err := marshalJSONMap(upsert.Metadata)
|
|
if err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("encode workspace agent interaction metadata: %w", err)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO workspace_agent_interactions (
|
|
workspace_id, agent_session_id, request_id, turn_id, kind, status, tool_name,
|
|
input_json, output_json, metadata_json, created_at_unix_ms, updated_at_unix_ms
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(workspace_id, agent_session_id, turn_id, request_id) DO UPDATE SET
|
|
kind = excluded.kind,
|
|
status = excluded.status,
|
|
tool_name = excluded.tool_name,
|
|
input_json = excluded.input_json,
|
|
output_json = excluded.output_json,
|
|
metadata_json = excluded.metadata_json,
|
|
updated_at_unix_ms = excluded.updated_at_unix_ms
|
|
`, workspaceID, agentSessionID, requestID, turnID, kind, status,
|
|
strings.TrimSpace(upsert.ToolName), inputJSON, outputJSON, metadataJSON,
|
|
occurred, occurred); err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("upsert workspace agent interaction: %w", err)
|
|
}
|
|
|
|
stored, ok, err := getAgentInteractionTx(ctx, tx, workspaceID, agentSessionID, turnID, requestID)
|
|
if err != nil {
|
|
return Interaction{}, InteractionTransitionConflict, err
|
|
}
|
|
if !ok {
|
|
return Interaction{}, InteractionTransitionConflict, fmt.Errorf("read upserted workspace agent interaction: %w", sql.ErrNoRows)
|
|
}
|
|
return stored, InteractionTransitionApplied, nil
|
|
}
|
|
|
|
func interactionImmutableIdentityEqual(existing Interaction, incoming InteractionUpsert) bool {
|
|
return existing.Kind == strings.TrimSpace(incoming.Kind) &&
|
|
existing.ToolName == strings.TrimSpace(incoming.ToolName) &&
|
|
jsonMapsEqual(existing.Input, incoming.Input) &&
|
|
jsonMapsEqual(existing.Metadata, incoming.Metadata)
|
|
}
|