feat(stats): show active time and elapsed span per task (#748)

* feat(stats): show active time and elapsed span per task

* fix(stats): address review feedback on task duration metrics

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-01 00:57:59 +01:00
committed by GitHub
parent 5d8152ce3f
commit b6c2b5a400
7 changed files with 80 additions and 7 deletions
@@ -13,6 +13,8 @@ type TaskStatsDTO struct {
UserMessageCount int `json:"user_message_count"`
ToolCallCount int `json:"tool_call_count"`
TotalDurationMs int64 `json:"total_duration_ms"`
ActiveDurationMs int64 `json:"active_duration_ms"`
ElapsedSpanMs int64 `json:"elapsed_span_ms"`
CreatedAt string `json:"created_at"`
CompletedAt *string `json:"completed_at,omitempty"`
}
@@ -154,6 +154,8 @@ func taskStatsToDTOs(taskStats []*models.TaskStats) []dto.TaskStatsDTO {
UserMessageCount: ts.UserMessageCount,
ToolCallCount: ts.ToolCallCount,
TotalDurationMs: ts.TotalDurationMs,
ActiveDurationMs: ts.ActiveDurationMs,
ElapsedSpanMs: ts.ElapsedSpanMs,
CreatedAt: ts.CreatedAt.UTC().Format(time.RFC3339),
}
if ts.CompletedAt != nil {
@@ -15,6 +15,8 @@ type TaskStats struct {
UserMessageCount int `json:"user_message_count"`
ToolCallCount int `json:"tool_call_count"`
TotalDurationMs int64 `json:"total_duration_ms"`
ActiveDurationMs int64 `json:"active_duration_ms"`
ElapsedSpanMs int64 `json:"elapsed_span_ms"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
@@ -2,6 +2,7 @@ package sqlite
import (
"context"
"math"
"path/filepath"
"testing"
"time"
@@ -132,6 +133,15 @@ func execOrFatal(t *testing.T, dbConn *sqlx.DB, query string, args ...any) {
}
}
func assertDurationAlmostEqual(t *testing.T, got, want int64, label string) {
t.Helper()
const toleranceMs = int64(2)
diff := int64(math.Abs(float64(got - want)))
if diff > toleranceMs {
t.Errorf("expected %s %dms (+/-%dms), got %dms", label, want, toleranceMs, got)
}
}
func TestEnsureStatsIndexes_CreatesIndexes(t *testing.T) {
dbConn := createTestDB(t)
@@ -372,6 +382,44 @@ func TestGetTaskStats_ExcludesEphemeralTasks(t *testing.T) {
}
}
func TestGetTaskStats_IncludesActiveAndElapsedDurations(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn, dbConn)
if err != nil {
t.Fatalf("NewWithDB failed: %v", err)
}
ctx := context.Background()
now := time.Now().UTC()
nowStr := now.Format(time.RFC3339)
execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'Task 1', 0, ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr)
// Two 10-minute turns with a 60-minute idle gap in between.
execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-1', 'sess-1', 'task-1', '2026-01-01T10:00:00Z', '2026-01-01T10:10:00Z', ?, ?)`, nowStr, nowStr)
execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-2', 'sess-1', 'task-1', '2026-01-01T11:10:00Z', '2026-01-01T11:20:00Z', ?, ?)`, nowStr, nowStr)
results, err := repo.GetTaskStats(ctx, "ws-1", nil)
if err != nil {
t.Fatalf("GetTaskStats failed: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 task stat, got %d", len(results))
}
// Active duration = 20m, elapsed span = 80m.
const (
wantActiveMs = int64(20 * 60 * 1000)
wantElapsedMs = int64(80 * 60 * 1000)
)
assertDurationAlmostEqual(t, results[0].ActiveDurationMs, wantActiveMs, "active duration")
assertDurationAlmostEqual(t, results[0].ElapsedSpanMs, wantElapsedMs, "elapsed span")
assertDurationAlmostEqual(t, results[0].TotalDurationMs, wantActiveMs, "total duration")
}
func TestGetDailyActivity_ExcludesEphemeralTasks(t *testing.T) {
dbConn := createTestDB(t)
repo, err := NewWithDB(dbConn, dbConn)
@@ -51,7 +51,9 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
COALESCE(session_stats.message_count, 0) as message_count,
COALESCE(session_stats.user_message_count, 0) as user_message_count,
COALESCE(session_stats.tool_call_count, 0) as tool_call_count,
COALESCE(turn_stats.total_duration_ms, 0) as total_duration_ms,
COALESCE(turn_stats.active_duration_ms, 0) as total_duration_ms,
COALESCE(turn_stats.active_duration_ms, 0) as active_duration_ms,
COALESCE(turn_stats.elapsed_span_ms, 0) as elapsed_span_ms,
t.created_at, session_stats.last_completed_at
FROM tasks t
LEFT JOIN (
@@ -70,7 +72,8 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
) session_stats ON session_stats.task_id = t.id
LEFT JOIN (
SELECT s.task_id,
SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as total_duration_ms
SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as active_duration_ms,
%s as elapsed_span_ms
FROM task_sessions s
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
WHERE (? IS NULL OR s.started_at >= ?)
@@ -78,7 +81,11 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
) turn_stats ON turn_stats.task_id = t.id
WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?)
ORDER BY t.updated_at DESC
`, dur)
`, dur, dialect.DurationMs(
drv,
"MAX(CASE WHEN turn.completed_at IS NOT NULL THEN turn.completed_at END)",
"MIN(CASE WHEN turn.completed_at IS NOT NULL THEN turn.started_at END)",
))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query),
startArg, startArg, startArg, startArg,
@@ -99,16 +106,21 @@ func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error)
var completedAtStr sql.NullString
var createdAtStr string
var totalDurationMs float64
var activeDurationMs float64
var elapsedSpanMs float64
err := rows.Scan(
&stat.TaskID, &stat.TaskTitle, &stat.WorkspaceID, &stat.WorkflowID, &stat.State,
&stat.SessionCount, &stat.TurnCount, &stat.MessageCount,
&stat.UserMessageCount, &stat.ToolCallCount, &totalDurationMs,
&activeDurationMs, &elapsedSpanMs,
&createdAtStr, &completedAtStr,
)
if err != nil {
return nil, err
}
stat.TotalDurationMs = int64(totalDurationMs)
stat.ActiveDurationMs = int64(activeDurationMs)
stat.ElapsedSpanMs = int64(elapsedSpanMs)
stat.CreatedAt = parseTimeString(createdAtStr)
if completedAtStr.Valid && completedAtStr.String != "" {
parsedTime := parseTimeString(completedAtStr.String)
+9 -4
View File
@@ -223,6 +223,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) {
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Longest Tasks</CardTitle>
<p className="text-xs text-muted-foreground">Ranked by elapsed span</p>
</CardHeader>
<CardContent>
<TaskDurationList
@@ -239,6 +240,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) {
<CardTitle className="text-sm font-medium text-muted-foreground">
Quickest Tasks
</CardTitle>
<p className="text-xs text-muted-foreground">Ranked by elapsed span</p>
</CardHeader>
<CardContent>
<TaskDurationList
@@ -433,11 +435,11 @@ export function RepoLeaders({ repositoryStats }: { repositoryStats: RepositorySt
}
function TaskDurationList({ tasks, sortDirection, emptyLabel }: TaskDurationListProps) {
const filtered = [...tasks].filter((t) => t.total_duration_ms > 0);
const filtered = [...tasks].filter((t) => t.active_duration_ms > 0 || t.elapsed_span_ms > 0);
const sorted =
sortDirection === "desc"
? filtered.sort((a, b) => b.total_duration_ms - a.total_duration_ms)
: filtered.sort((a, b) => a.total_duration_ms - b.total_duration_ms);
? filtered.sort((a, b) => b.elapsed_span_ms - a.elapsed_span_ms)
: filtered.sort((a, b) => a.elapsed_span_ms - b.elapsed_span_ms);
const top3 = sorted.slice(0, 3);
return (
@@ -454,7 +456,10 @@ function TaskDurationList({ tasks, sortDirection, emptyLabel }: TaskDurationList
</div>
</div>
<div className="text-sm font-medium tabular-nums text-right">
{formatDuration(task.total_duration_ms)}
<div>{formatDuration(task.active_duration_ms)}</div>
<div className="text-[11px] text-muted-foreground">
span {formatDuration(task.elapsed_span_ms)}
</div>
</div>
</div>
))}
+2
View File
@@ -297,6 +297,8 @@ export type TaskStatsDTO = {
user_message_count: number;
tool_call_count: number;
total_duration_ms: number;
active_duration_ms: number;
elapsed_span_ms: number;
created_at: string;
completed_at?: string;
};