Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/views/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "@multica/tsconfig/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@claud-ometer/envelope": ["../../../Claud-ometer/src/lib/command/envelope.ts"]
}
Expand Down
104 changes: 104 additions & 0 deletions server/cmd/server/runtime_sweeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"errors"
"log/slog"
"time"

Expand Down Expand Up @@ -60,6 +61,17 @@ const (
// ticks and 500 rows/tick we drain 60k rows/hour worst case — plenty
// of headroom for the documented backlog without monopolising DB CPU.
queuedExpireBatchSize = 500
// todoDispatchReclaimBatchSize caps the recovery pass that re-enqueues
// todo issues whose prior task failed or vanished. The pass runs every
// sweeper tick, so a modest cap repairs wedges quickly without flooding
// daemons after an outage.
todoDispatchReclaimBatchSize = 50
// todoDispatchReclaimCooldownSeconds prevents infinite re-enqueue loops
// by waiting 15 minutes after a task failure before reclaiming it.
todoDispatchReclaimCooldownSeconds = 15 * 60.0
// todoDispatchReclaimMaxAttempts is the breaker limit. If an issue fails
// this many times, it stops being reclaimed and is marked blocked.
todoDispatchReclaimMaxAttempts = 5
)

// runRuntimeSweeper periodically marks runtimes as offline if their
Expand All @@ -85,6 +97,7 @@ func runRuntimeSweeper(ctx context.Context, queries *db.Queries, liveness handle
sweepStaleRuntimes(ctx, queries, liveness, taskSvc, bus)
sweepStaleTasks(ctx, queries, taskSvc, bus)
sweepExpiredQueuedTasks(ctx, queries, taskSvc)
sweepTodoDispatchReclaim(ctx, queries, taskSvc)
gcRuntimes(ctx, queries, bus)
}
}
Expand Down Expand Up @@ -285,6 +298,97 @@ func sweepExpiredQueuedTasks(ctx context.Context, queries *db.Queries, taskSvc *
taskSvc.HandleFailedTasks(ctx, failedTasks)
}

type dispatchReclaimStats struct {
Scanned int
Reclaimed int
Routed int
Failed int
Skipped map[string]int
}

func (s *dispatchReclaimStats) skip(reason string) {
if s.Skipped == nil {
s.Skipped = make(map[string]int)
}
s.Skipped[reason]++
}

// sweepTodoDispatchReclaim repairs todo issues whose dispatch path wedged after
// a failed or missing task row. The candidate list is a bounded picker only; the
// service revalidates and enqueues under an issue lock so recovery never cancels
// a task that raced in after the sweep began.
func sweepTodoDispatchReclaim(ctx context.Context, queries *db.Queries, taskSvc *service.TaskService) dispatchReclaimStats {
stats := dispatchReclaimStats{Skipped: make(map[string]int)}
if taskSvc == nil {
stats.skip("task_service_missing")
return stats
}

candidates, err := queries.ListTodoDispatchReclaimCandidates(ctx, db.ListTodoDispatchReclaimCandidatesParams{
MaxPerTick: todoDispatchReclaimBatchSize,
CooldownSecs: todoDispatchReclaimCooldownSeconds,
})
if err != nil {
stats.Failed++
slog.Warn("todo dispatch reclaim: failed to list candidates", "error", err)
return stats
}
stats.Scanned = len(candidates)

for _, c := range candidates {
issueID := c.ID
issueKey := util.UUIDToString(issueID)
targetAgentID := util.UUIDToString(c.TargetAgentID)

if c.FailedTasksCount >= todoDispatchReclaimMaxAttempts {
stats.skip("breaker_tripped")
_, blockErr := queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: issueID,
Status: "blocked",
WorkspaceID: c.WorkspaceID,
})
if blockErr != nil {
slog.Warn("todo dispatch reclaim: failed to block issue", "issue_id", issueKey, "error", blockErr)
} else {
slog.Info("todo dispatch reclaim: issue tripped breaker and blocked", "issue_id", issueKey, "failed_tasks", c.FailedTasksCount)
}
continue
Comment on lines +343 to +355

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the breaker transition part of the locked recovery transaction.

FailedTasksCount is a stale list snapshot. Before this update, the issue can be completed, reassigned, or receive a new active task, yet this code still overwrites its status with blocked. Move the attempt check and status transition behind the issue lock used by RecoverTodoDispatch. Only record breaker_tripped after a successful update; count update failures in Failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/cmd/server/runtime_sweeper.go` around lines 343 - 355, The breaker
decision and status update around RecoverTodoDispatch must run under the issue
lock, using fresh issue/task state rather than the stale FailedTasksCount
snapshot. Move the attempt check and blocked transition into the locked recovery
transaction; record breaker_tripped only after a successful status update, and
increment Failed when that update fails.

}

_, isSquadRoute, err := taskSvc.RecoverTodoDispatch(ctx, issueID, todoDispatchReclaimCooldownSeconds)
if errors.Is(err, service.ErrTodoDispatchReclaimNotEligible) {
stats.skip("candidate_changed")
continue
}
if err != nil {
stats.Failed++
slog.Warn("todo dispatch reclaim: recovery enqueue failed",
"issue_id", issueKey,
"target_agent_id", targetAgentID,
"is_squad_route", c.IsSquadRoute,
"error", err,
)
continue
}
if isSquadRoute {
stats.Routed++
} else {
stats.Reclaimed++
}
}

if stats.Scanned > 0 || stats.Reclaimed > 0 || stats.Routed > 0 || stats.Failed > 0 {
slog.Info("todo dispatch reclaim: completed",
"scanned", stats.Scanned,
"reclaimed", stats.Reclaimed,
"routed", stats.Routed,
"skipped", stats.Skipped,
"failed", stats.Failed,
)
}
return stats
}

// broadcastFailedTasks is preserved as a thin shim for the integration tests
// in this package. New call sites should use TaskService.HandleFailedTasks
// directly so the side effects (event broadcast, agent reconcile, issue
Expand Down
Loading
Loading