Files
kandev/apps/backend/internal/runs/repository/sqlite/runs.go
T

897 lines
32 KiB
Go

package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/runs/commentkeys"
)
// CreateRunTx creates a new run queue entry using a transaction the caller
// owns. Callers that need to combine the insert with other transactional
// writes (for example the parent-wake reconciler's receipt upsert) use
// this directly; CreateRun wraps it with a private transaction for the
// common single-statement case.
//
// ContinuationScope is decided here, once, before the row exists to be
// coalesced into — never at read or write time downstream. A routine
// wakeup that later coalesces into this run (MarkWakeupRequestCoalesced)
// only ever patches context_snapshot, so every later reader/writer of
// this run's continuation summary reads the value persisted here instead
// of re-deriving it against a snapshot that may have drifted.
func (r *Repository) CreateRunTx(ctx context.Context, tx *sqlx.Tx, req *models.Run) error {
if req.ID == "" {
req.ID = uuid.New().String()
}
ensureRunDefaults(req)
req.RequestedAt = time.Now().UTC()
req.ContinuationScope = models.ContinuationScopeForRun(req, req.AgentProfileID)
_, err := tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO runs (
id, agent_profile_id, reason, payload, status, coalesced_count,
idempotency_key, context_snapshot, capabilities, input_snapshot,
output_summary, failure_reason, session_id, retry_count, scheduled_retry_at,
requested_at, error_message, cancel_reason, continuation_scope,
wake_wave_key, wake_wave_string, causation_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), req.ID, req.AgentProfileID, req.Reason, req.Payload, req.Status,
req.CoalescedCount, req.IdempotencyKey, req.ContextSnapshot,
req.Capabilities, req.InputSnapshot, req.OutputSummary, req.FailureReason,
req.SessionID, req.RetryCount, req.ScheduledRetryAt, req.RequestedAt,
req.ErrorMessage, req.CancelReason, req.ContinuationScope,
req.WakeWaveKey, req.WakeWaveString, req.CausationID)
return err
}
// CreateRun creates a new run queue entry in a transaction owned by this
// method. See CreateRunTx for the field-defaulting / continuation-scope
// derivation this delegates to.
func (r *Repository) CreateRun(ctx context.Context, req *models.Run) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.CreateRunTx(ctx, tx, req); err != nil {
return err
}
return tx.Commit()
}
func ensureRunDefaults(req *models.Run) {
if req.Payload == "" {
req.Payload = "{}"
}
if req.ContextSnapshot == "" {
req.ContextSnapshot = "{}"
}
if req.Capabilities == "" {
req.Capabilities = "{}"
}
if req.InputSnapshot == "" {
req.InputSnapshot = "{}"
}
if req.ResultJSON == "" {
req.ResultJSON = "{}"
}
if req.Status == "" {
req.Status = "queued"
}
}
// UpdateRunRuntimeSnapshot stores the runtime context captured before launch.
func (r *Repository) UpdateRunRuntimeSnapshot(
ctx context.Context,
id string,
capabilities string,
inputSnapshot string,
sessionID string,
) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET capabilities = ?, input_snapshot = ?, session_id = ?
WHERE id = ?
`), capabilities, inputSnapshot, sessionID, id)
return err
}
// SetRunSessionID persists the session id a launch produced. A no-op
// when sessionID is empty — the caller counts that as a without-session
// launch rather than clobbering whatever the column already held.
// Guarded to status = 'claimed' so a launch that loses a race against a
// concurrent cancel or terminal write cannot mutate an already-terminal
// row's session id after the fact: without the guard, a terminal-shape
// classification already recorded off the empty session id would drift
// from what the row shows on a later read. Returns whether the row was
// still claimed so the caller can distinguish a real write from a
// stale run id or a lost race.
func (r *Repository) SetRunSessionID(
ctx context.Context, runID, sessionID string,
) (bool, error) {
if sessionID == "" {
return false, nil
}
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs SET session_id = ? WHERE id = ? AND status = 'claimed'
`), sessionID, runID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// UpdateRunRuntimeSnapshotCAS is UpdateRunRuntimeSnapshot's compare-and-swap
// sibling: the write only takes effect while the run's current
// capabilities still equal prevCapabilities. Used to decide first-write-
// wins when two processors build runtime context for the same run
// concurrently (docs/specs/office/system-design/
// taskless-coordinator-authority-01.md#first-write-wins-and-how). The
// comparison is a value compare rather than a SQL JSON extraction so it
// stays dialect-neutral across SQLite and Postgres. The bool reports
// whether this call's write took effect.
func (r *Repository) UpdateRunRuntimeSnapshotCAS(
ctx context.Context,
id string,
prevCapabilities string,
capabilities string,
inputSnapshot string,
sessionID string,
) (bool, error) {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET capabilities = ?, input_snapshot = ?, session_id = ?
WHERE id = ? AND COALESCE(capabilities, '') = ?
`), capabilities, inputSnapshot, sessionID, id, prevCapabilities)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
// UpdateRunPromptArtifacts persists the assembled prompt the agent
// received and the continuation-summary content prepended at dispatch.
// Called from the scheduler-integration after BuildAgentPrompt completes
// so the run-detail UI can render exactly what the agent saw. Either
// argument may be empty — the columns default to ” and tolerate it.
func (r *Repository) UpdateRunPromptArtifacts(
ctx context.Context, id, assembledPrompt, summaryInjected string,
) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET assembled_prompt = ?, summary_injected = ?
WHERE id = ?
`), assembledPrompt, summaryInjected, id)
return err
}
// UpdateRunResultJSON stores the structured adapter output captured
// at run completion. Used by the continuation-summary builder as the
// primary input for "Recent actions" / "Recent decisions". Falls back
// to '{}' when raw is empty so the column default invariant holds.
func (r *Repository) UpdateRunResultJSON(ctx context.Context, id, raw string) error {
if raw == "" {
raw = "{}"
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET result_json = ?
WHERE id = ?
`), raw, id)
return err
}
// UpdateRunOutputSummary stores the final runtime summary for a run.
func (r *Repository) UpdateRunOutputSummary(ctx context.Context, id, outputSummary, failureReason string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET output_summary = ?, failure_reason = ?
WHERE id = ?
`), outputSummary, failureReason, id)
return err
}
// ListRuns returns run requests filtered by workspace (via agent profile join), ordered by time.
func (r *Repository) ListRuns(ctx context.Context, workspaceID string) ([]*models.Run, error) {
var reqs []*models.Run
err := r.ro.SelectContext(ctx, &reqs, r.ro.Rebind(`
SELECT w.* FROM runs w
JOIN agent_profiles a ON a.id = w.agent_profile_id
WHERE a.workspace_id = ?
ORDER BY w.requested_at DESC
`), workspaceID)
if err != nil {
return nil, err
}
if reqs == nil {
reqs = []*models.Run{}
}
return reqs, nil
}
// ClaimRun atomically claims the oldest queued run for an agent.
func (r *Repository) ClaimRun(ctx context.Context, agentInstanceID string) (*models.Run, error) {
now := time.Now().UTC()
var req models.Run
err := r.db.QueryRowxContext(ctx, r.db.Rebind(`
UPDATE runs
SET status = 'claimed', claimed_at = ?
WHERE id = (
SELECT id FROM runs
WHERE agent_profile_id = ? AND status = 'queued'
ORDER BY requested_at ASC
LIMIT 1
)
RETURNING *
`), now, agentInstanceID).StructScan(&req)
if err != nil {
return nil, err
}
return &req, nil
}
// FinishRun marks a run as terminal (status) and records its outcome in the
// same statement, so the row can never hold a terminal status with a stale
// outcome from a different transition. outcome is nil for the failed path
// and for callers with no established semantic label (docs/specs/
// task-delivery-ledger/spec.md, "Office run outcome").
// FinishRun writes the terminal status/outcome and returns the row as it
// stands immediately after that write, via the same statement (RETURNING),
// so a caller classifying the transition (office_loop_terminal_total) never
// depends on a separate read succeeding independently of the write that
// persisted it. Guarded to status = 'claimed', the same status every caller
// reaches this from (ClaimNextEligibleRun then processRun/an event
// subscriber): without the guard, a cancel that commits between the
// caller's read and this write would have its 'cancelled' status and
// finished_at overwritten by this transition. Returns (nil, nil) for an
// unknown id or a run no longer claimed (already terminal by another
// writer): zero rows changed, so there is nothing to classify.
func (r *Repository) FinishRun(ctx context.Context, id, status string, outcome *string) (*models.Run, error) {
now := time.Now().UTC()
var run models.Run
err := r.db.QueryRowxContext(ctx, r.db.Rebind(`
UPDATE runs SET status = ?, outcome = ?, finished_at = ?
WHERE id = ? AND status = 'claimed'
RETURNING *
`), status, outcome, now, id).StructScan(&run)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &run, nil
}
// GetRunByID returns the run row for a given ID. Returns sql.ErrNoRows when unknown.
func (r *Repository) GetRunByID(ctx context.Context, id string) (*models.Run, error) {
var run models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs WHERE id = ?
`), id).StructScan(&run)
if err != nil {
return nil, err
}
return &run, nil
}
// GetRunWorkspaceID resolves a run's owning workspace via its agent
// profile, the same join idiom ListRuns uses. A raw join, not
// GetAgentInstance: GetAgentInstance filters deleted_at IS NULL, which
// would make a run under a soft-deleted agent profile permanently
// unresolvable (and so permanently deniable) to its own owner. Returns
// sql.ErrNoRows for an unknown run.
func (r *Repository) GetRunWorkspaceID(ctx context.Context, runID string) (string, error) {
var workspaceID string
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT a.workspace_id FROM runs r
JOIN agent_profiles a ON a.id = r.agent_profile_id
WHERE r.id = ?
`), runID).Scan(&workspaceID)
if err != nil {
return "", err
}
return workspaceID, nil
}
// GetClaimedRunByID returns a run only while it is still claimed. Lifecycle
// events carry this immutable run identity so a delayed predecessor event
// cannot finish a newer claimed run for the same task and agent.
func (r *Repository) GetClaimedRunByID(ctx context.Context, id string) (*models.Run, error) {
var run models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs WHERE id = ? AND status = 'claimed'
`), id).StructScan(&run)
if err != nil {
return nil, err
}
return &run, nil
}
// CommentRunStatus is the slim per-comment run snapshot returned by
// GetRunsByCommentIDs. The backend maps these onto CommentDTO so the
// frontend can render a Queued / Working / Failed badge on the user
// comment that triggered each run.
type CommentRunStatus struct {
RunID string
Status string
ErrorMessage string
}
// GetRunsByCommentIDs returns the latest run associated with each comment id.
// The canonical same-task wake joins on idempotency_key =
// "task_comment:<comment_id>"; salted fan-out keys join through the persisted
// payload.comment_id so cross-task comment wakes still surface status.
// Comments without a matching run are simply absent from the map.
// When a comment has multiple matching rows (rare — would require the
// idempotency window to lapse and the comment to re-trigger) the most
// recently requested row wins.
func (r *Repository) GetRunsByCommentIDs(
ctx context.Context, commentIDs []string,
) (map[string]CommentRunStatus, error) {
out := map[string]CommentRunStatus{}
if len(commentIDs) == 0 {
return out, nil
}
args := make([]interface{}, 0)
keyPlaceholders := make([]string, len(commentIDs))
commentPlaceholders := make([]string, len(commentIDs))
wanted := make(map[string]struct{}, len(commentIDs))
for i, id := range commentIDs {
args = append(args, commentkeys.TaskComment(id))
keyPlaceholders[i] = "?"
wanted[id] = struct{}{}
}
for _, id := range commentIDs {
args = append(args, commentkeys.TaskComment(id))
}
args = append(args, commentkeys.TaskCommentPrefix+"%", commentkeys.TaskCommentReason)
for i, id := range commentIDs {
args = append(args, id)
commentPlaceholders[i] = "?"
}
commentIDExpr := dialect.JSONExtract(r.ro.DriverName(), "payload", "comment_id")
query := fmt.Sprintf(`
SELECT id, idempotency_key, status, error_message, requested_at, payload
FROM runs
WHERE idempotency_key IN (%s)
UNION ALL
SELECT id, idempotency_key, status, error_message, requested_at, payload
FROM runs
WHERE (idempotency_key IS NULL OR idempotency_key NOT IN (%s))
AND (idempotency_key LIKE ? OR reason = ?)
AND %s IN (%s)
ORDER BY requested_at DESC
`,
strings.Join(keyPlaceholders, ","),
strings.Join(keyPlaceholders, ","),
commentIDExpr,
strings.Join(commentPlaceholders, ","),
)
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id, status, errMsg, payload string
idemKey sql.NullString
requestedAt time.Time
)
if err := rows.Scan(&id, &idemKey, &status, &errMsg, &requestedAt, &payload); err != nil {
return nil, err
}
commentID := commentIDFromRun(idemKey.String, payload, wanted)
if commentID == "" {
continue
}
// First write wins — rows are ordered DESC so the first row per
// comment id is the most recent.
if _, ok := out[commentID]; ok {
continue
}
out[commentID] = CommentRunStatus{
RunID: id,
Status: status,
ErrorMessage: errMsg,
}
}
return out, rows.Err()
}
func commentIDFromRun(idempotencyKey, payload string, wanted map[string]struct{}) string {
payloadID := commentIDFromPayload(payload, wanted)
if commentkeys.IsSaltedTaskCommentKey(idempotencyKey) && payloadID != "" {
return payloadID
}
if id := commentkeys.CommentIDFromKey(idempotencyKey); id != "" {
if _, found := wanted[id]; found {
return id
}
}
return payloadID
}
func commentIDFromPayload(payload string, wanted map[string]struct{}) string {
if payload == "" {
return ""
}
var raw map[string]any
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
return ""
}
id, ok := raw["comment_id"].(string)
if !ok {
return ""
}
if _, found := wanted[id]; !found {
return ""
}
return id
}
// GetClaimedTasklessRunForAgent returns the most recently claimed
// taskless run (payload.task_id is empty / missing) for the given
// agent. Used by the AgentCompleted event subscriber to attribute a
// fresh-session heartbeat run when no task_id is on the event. Returns
// sql.ErrNoRows when no such run exists — the caller should treat
// that as "not a heartbeat completion event".
func (r *Repository) GetClaimedTasklessRunForAgent(
ctx context.Context, agentProfileID string,
) (*models.Run, error) {
var req models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs
WHERE agent_profile_id = ?
AND status = 'claimed'
AND COALESCE(json_extract(payload, '$.task_id'), '') = ''
ORDER BY claimed_at DESC
LIMIT 1
`), agentProfileID).StructScan(&req)
if err != nil {
return nil, err
}
return &req, nil
}
// GetClaimedRunByTaskID returns the claimed run associated with a task payload.
func (r *Repository) GetClaimedRunByTaskID(ctx context.Context, taskID string) (*models.Run, error) {
var req models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs
WHERE status = 'claimed'
AND json_extract(payload, '$.task_id') = ?
ORDER BY claimed_at DESC
LIMIT 1
`), taskID).StructScan(&req)
if err != nil {
return nil, err
}
return &req, nil
}
// GetClaimedRunByTaskAndAgent returns the claimed run for a task that
// belongs to a specific agent. Unlike GetClaimedRunByTaskID, this is scoped
// to the agent as well as the task: ClaimNextEligibleRun's busy-lock is
// per-agent, not per-task, so two different agents can each have a claimed
// run on the same task at once. Callers that already know which agent's
// lifecycle event they are handling (AgentCompleted/AgentFailed) must use
// this instead of the unscoped lookup, which prefers whichever row was
// claimed most recently regardless of which agent actually sent the event
// (Review round 4, BLOCKING FINDING 2).
func (r *Repository) GetClaimedRunByTaskAndAgent(
ctx context.Context, taskID, agentProfileID string,
) (*models.Run, error) {
var req models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs
WHERE status = 'claimed'
AND agent_profile_id = ?
AND json_extract(payload, '$.task_id') = ?
ORDER BY claimed_at DESC
LIMIT 1
`), agentProfileID, taskID).StructScan(&req)
if err != nil {
return nil, err
}
return &req, nil
}
// CheckIdempotencyKey returns true if the key already exists within the window.
func (r *Repository) CheckIdempotencyKey(ctx context.Context, key string, windowHours int) (bool, error) {
cutoff := time.Now().UTC().Add(-time.Duration(windowHours) * time.Hour)
var count int
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COUNT(*) FROM runs
WHERE idempotency_key = ? AND requested_at > ?
`), key, cutoff).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// CoalesceRun tries to merge with an existing queued run for the same
// agent and reason within the given window. Returns true if coalesced.
func (r *Repository) CoalesceRun(
ctx context.Context, agentInstanceID, reason string, windowSecs int, payload string,
) (bool, error) {
cutoff := time.Now().UTC().Add(-time.Duration(windowSecs) * time.Second)
taskID, invalidTaskID := taskIDFromPayload(payload)
args := []interface{}{payload, agentInstanceID, reason, cutoff, commentkeys.TaskCommentPrefix + "%"}
// A payload's task_id identifies which launch it belongs to: merging
// across two different task_ids (present or absent) would replace one
// launch's payload with an unrelated one and silently drop it. The
// check is symmetric so both directions are covered. json_extract (and
// its Postgres ->> equivalent) yields NULL for both an absent key and
// an explicit JSON null, and '' for a present-but-empty string, so the
// taskless branch coalesces all three shapes together via COALESCE.
jsonExtract := dialect.JSONExtract(r.db.DriverName(), "payload", "task_id")
var taskPredicate string
switch {
case invalidTaskID:
// task_id is present but not a string (e.g. a number): it names a
// task we can't compare textually, so it must not be treated as
// taskless and must not match any queued row at all.
taskPredicate = " AND 1 = 0"
case taskID != "":
// The stored value must itself be a JSON string, not merely equal
// as text: Postgres's ->> converts a stored JSON number (or
// object) to text before the comparison, so an untyped payload
// with e.g. {"task_id":42} could otherwise textually match an
// incoming {"task_id":"42"} and get overwritten.
taskPredicate = fmt.Sprintf(" AND %s AND %s = ?",
dialect.JSONTypeIsString(r.db.DriverName(), "payload", "task_id"), jsonExtract)
args = append(args, taskID)
default:
taskPredicate = fmt.Sprintf(" AND COALESCE(%s, '') = ''", jsonExtract)
}
query := fmt.Sprintf(`
UPDATE runs
SET coalesced_count = coalesced_count + 1, payload = ?
WHERE id = (
SELECT id FROM runs
WHERE agent_profile_id = ? AND reason = ? AND status = 'queued'
AND requested_at > ?
AND (idempotency_key IS NULL OR idempotency_key NOT LIKE ?)
AND wake_wave_key = ''
%s
ORDER BY requested_at DESC
LIMIT 1
)
`, taskPredicate)
res, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return false, err
}
rows, err := res.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
// taskIDFromPayload extracts payload.task_id for CoalesceRun's task-scoping
// predicate. invalidTaskID is true only when the key is present with a
// non-string value: that shape names some task_id, just not one comparable
// as a string, so it must be kept out of the taskless bucket (an absent key,
// a JSON null, or a present empty string all return "", invalidTaskID=false).
func taskIDFromPayload(payload string) (taskID string, invalidTaskID bool) {
var raw map[string]any
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
return "", false
}
v, present := raw["task_id"]
if !present || v == nil {
return "", false
}
s, ok := v.(string)
if !ok {
return "", true
}
return s, false
}
// ClaimNextEligibleRun atomically claims the next queued run,
// skipping runs with a scheduled retry time in the future and
// agents that already have a claimed run. Agent status and cooldown
// checks are performed in the service layer.
func (r *Repository) ClaimNextEligibleRun(ctx context.Context) (*models.Run, error) {
now := time.Now().UTC()
var req models.Run
err := r.db.QueryRowxContext(ctx, r.db.Rebind(`
UPDATE runs
SET status = 'claimed', claimed_at = ?
WHERE id = (
SELECT w.id FROM runs w
WHERE w.status = 'queued'
AND (
SELECT COUNT(*) FROM runs cw
WHERE cw.agent_profile_id = w.agent_profile_id
AND cw.status = 'claimed'
) = 0
AND (w.scheduled_retry_at IS NULL OR w.scheduled_retry_at <= ?)
AND w.routing_blocked_status IS NULL
ORDER BY w.requested_at ASC
LIMIT 1
)
RETURNING *
`), now, now).StructScan(&req)
if err != nil {
return nil, err
}
return &req, nil
}
// ScheduleRetry resets a run to queued with an incremented retry count
// and a scheduled retry time. session_id is cleared: every caller either
// runs pre-launch (the run never had one) or post-start (the session it
// had belongs to the failed attempt), and a relaunch must mint its
// runtime credentials against the session the new attempt actually gets,
// not a stale one from a previous attempt. error_message is cleared for
// the same reason: a requeued run is not yet failed, so it must not carry
// the previous attempt's error into a later successful finish.
func (r *Repository) ScheduleRetry(ctx context.Context, runID string, retryAt time.Time, retryCount int) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET status = 'queued', retry_count = ?, scheduled_retry_at = ?,
claimed_at = NULL, finished_at = NULL, session_id = '', error_message = ''
WHERE id = ?
`), retryCount, retryAt, runID)
return err
}
// ScheduleRetryIfClaimed behaves like ScheduleRetry but only when the run
// is still status='claimed', mirroring the same guard MarkRunFailed uses.
// A caller that wants to requeue a run it has not itself moved off
// 'claimed' must not resurrect a row a concurrent writer already
// terminalized (a task-tree cancel, workspace pause, or participant
// eviction) out from under it — every one of those writers targets
// exactly the 'claimed' status this guard checks. wrote=false means a
// concurrent writer already changed the row's status; the caller must
// not retry again or treat the run as requeued.
func (r *Repository) ScheduleRetryIfClaimed(ctx context.Context, runID string, retryAt time.Time, retryCount int) (bool, error) {
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET status = 'queued', retry_count = ?, scheduled_retry_at = ?,
claimed_at = NULL, finished_at = NULL, session_id = '', error_message = ''
WHERE id = ? AND status = 'claimed'
`), retryCount, retryAt, runID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// CleanExpired deletes finished/failed runs older than the given time.
func (r *Repository) CleanExpired(ctx context.Context, olderThan time.Time) (int64, error) {
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
DELETE FROM runs
WHERE status IN ('finished', 'failed') AND finished_at < ?
`), olderThan)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// RecoverStale resets claimed runs older than the given time back to queued.
func (r *Repository) RecoverStale(ctx context.Context, claimedOlderThan time.Time) (int64, error) {
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET status = 'queued', claimed_at = NULL
WHERE status = 'claimed' AND claimed_at < ?
`), claimedOlderThan)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ListPendingRunsForTask returns queued runs in retry state for the given task.
func (r *Repository) ListPendingRunsForTask(ctx context.Context, taskID string) ([]*models.Run, error) {
var reqs []*models.Run
err := r.ro.SelectContext(ctx, &reqs, r.ro.Rebind(`
SELECT * FROM runs
WHERE status = 'queued'
AND scheduled_retry_at IS NOT NULL
AND json_extract(payload, '$.task_id') = ?
`), taskID)
if err != nil {
return nil, err
}
if reqs == nil {
reqs = []*models.Run{}
}
return reqs, nil
}
// FindInflightRunForAgent returns the most-recent in-flight run for
// the given agent. "In-flight" means status='queued' (with or without a
// scheduled retry) or status='claimed'. The dispatcher uses this for
// claim-time coalescing — when a wakeup-request lands and an in-flight
// run already exists for the agent, the new request is merged into the
// existing run rather than creating a fresh one.
//
// Returns sql.ErrNoRows when no such run exists.
func (r *Repository) FindInflightRunForAgent(
ctx context.Context, agentProfileID string,
) (*models.Run, error) {
var run models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs
WHERE agent_profile_id = ?
AND status IN ('queued', 'claimed')
ORDER BY requested_at DESC
LIMIT 1
`), agentProfileID).StructScan(&run)
if err != nil {
return nil, err
}
return &run, nil
}
// ListRunsForAgentPaged returns runs for an agent ordered by
// (requested_at DESC, id DESC), starting strictly after the given
// cursor. Pass cursor.IsZero() == true to fetch the first page.
// Returns at most `limit` rows; tie-break on id keeps adjacent rows
// with identical requested_at strictly ordered. The handler is
// expected to derive next_cursor from the last row's requested_at.
func (r *Repository) ListRunsForAgentPaged(
ctx context.Context, agentInstanceID string, cursor time.Time, cursorID string, limit int,
) ([]*models.Run, error) {
if limit <= 0 {
limit = 25
}
var reqs []*models.Run
if cursor.IsZero() {
err := r.ro.SelectContext(ctx, &reqs, r.ro.Rebind(`
SELECT * FROM runs
WHERE agent_profile_id = ?
ORDER BY requested_at DESC, id DESC
LIMIT ?
`), agentInstanceID, limit)
if err != nil {
return nil, err
}
} else {
// Strictly after the cursor (requested_at, id) DESC: a row is
// "after" when its requested_at < cursor, OR requested_at ==
// cursor AND id < cursorID.
err := r.ro.SelectContext(ctx, &reqs, r.ro.Rebind(`
SELECT * FROM runs
WHERE agent_profile_id = ?
AND (requested_at < ? OR (requested_at = ? AND id < ?))
ORDER BY requested_at DESC, id DESC
LIMIT ?
`), agentInstanceID, cursor, cursor, cursorID, limit)
if err != nil {
return nil, err
}
}
if reqs == nil {
reqs = []*models.Run{}
}
return reqs, nil
}
// RunCostRollup carries the per-run aggregated token + cost numbers
// returned by GetRunWithCosts. Cost rows are joined to the run via
// the run payload's task_id; if the run has no cost rows yet the
// fields are zero. CostSubcents stores hundredths of a cent (UI divides
// by 10000).
type RunCostRollup struct {
InputTokens int64 `db:"input_tokens" json:"input_tokens"`
OutputTokens int64 `db:"output_tokens" json:"output_tokens"`
CachedTokens int64 `db:"cached_tokens" json:"cached_tokens"`
CostSubcents int64 `db:"cost_subcents" json:"cost_subcents"`
}
// GetRunWithCosts returns the run row plus a token + cost rollup
// computed by joining office_cost_events on the task_id stored in
// the run payload (json_extract). Returns sql.ErrNoRows when the run
// id is unknown. The rollup may be all-zero when the run hasn't
// produced cost events yet.
func (r *Repository) GetRunWithCosts(
ctx context.Context, runID string,
) (*models.Run, *RunCostRollup, error) {
var run models.Run
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM runs WHERE id = ?
`), runID).StructScan(&run)
if err != nil {
return nil, nil, err
}
var rollup RunCostRollup
err = r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT
COALESCE(SUM(tokens_in), 0) AS input_tokens,
COALESCE(SUM(tokens_out), 0) AS output_tokens,
COALESCE(SUM(tokens_cached_in), 0) AS cached_tokens,
COALESCE(SUM(cost_subcents), 0) AS cost_subcents
FROM office_cost_events
WHERE (task_id != '' AND task_id = COALESCE(json_extract(?, '$.task_id'), ''))
OR (task_id = '' AND session_id IN (SELECT id FROM office_run_sessions WHERE run_id = ?))
`), run.Payload, run.ID).StructScan(&rollup)
if err != nil {
return &run, &RunCostRollup{}, nil
}
return &run, &rollup, nil
}
// SetRunRequestedAtForTest backfills requested_at for a seeded run.
// Test-only: bypasses the normal CreateRun stamping so E2E specs can
// build deterministic ordered pages without sleeping between writes.
func (r *Repository) SetRunRequestedAtForTest(
ctx context.Context, runID string, ts time.Time,
) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs SET requested_at = ? WHERE id = ?
`), ts, runID)
return err
}
// SetRunStatusForTest forces the status + timing fields for a seeded
// run. Test-only: lets the E2E harness land non-queued rows
// (claimed/finished/failed/cancelled) without going through the
// production state machine. A "finished" status defaults outcome to
// "processed", mirroring the ordinary production call site
// (event_subscribers.go's agent-completed path) that "finished" almost
// always represents — the same default already applied by
// office/dashboard's seedSummaryRun test helper (docs/specs/
// task-delivery-ledger/spec.md, "Office run outcome"). Every other
// status writes a NULL outcome, matching FailRun and the dormant
// SchedulerService paths. Existing callers that only cared about
// status keep reading as succeeded under the outcome-aware bucketing
// with no call-site changes.
func (r *Repository) SetRunStatusForTest(
ctx context.Context, runID, status string,
claimedAt, finishedAt *time.Time,
) error {
var outcome interface{}
if status == "finished" {
outcome = "processed"
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs
SET status = ?, outcome = ?, claimed_at = ?, finished_at = ?
WHERE id = ?
`), status, outcome, claimedAt, finishedAt, runID)
return err
}
// SetRunErrorMessageForTest forces the error_message field on a run row.
// Test-only: lets the harness simulate a failed run carrying the same
// error string a real agent_failed event would have produced.
func (r *Repository) SetRunErrorMessageForTest(
ctx context.Context, runID, errMsg string,
) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE runs SET error_message = ? WHERE id = ?
`), errMsg, runID)
return err
}