Compare commits

...

1 Commits

Author SHA1 Message Date
fullstack-engineer 3a5c25530f fix(handlers): deduplicate activity_logs rows by delegation_id in ListDelegations fallback
The activity_logs fallback path in GET /workspaces/:id/delegations returned
both the initial 'delegate' row (status=pending/in_progress) and the
terminal 'delegate_result' row (status=completed/failed) for the same
delegation_id. The agent's _refresh_queued_from_platform iterates these
rows and would scan the stale initial row first, never discovering the
terminal outcome.

Fix: Go-side deduplication in listDelegationsFromActivityLogs. Rows are
scanned in created_at DESC order; for each non-empty delegation_id the
first occurrence is kept and subsequent older duplicates are skipped.
Rows with an empty delegation_id (pre-#318 records) are returned as-is
since they cannot be correlated.

Also fixes delegation_list_test.go activity_type values to match actual
handler inserts: all delegation activity_logs rows carry activity_type
'delegation' (not 'delegate' / 'delegate_result').

New tests:
- TestListDelegationsFromActivityLogs_DeduplicationKeepsNewest: verifies
  that when both rows exist for a delegation, only the newest is returned
- TestListDelegationsFromActivityLogs_DeduplicationDistinctDelegations:
  verifies distinct delegation_ids are all returned
- TestListDelegationsFromActivityLogs_DeduplicationMixedTerminalStatuses:
  verifies completed and failed terminal rows are correctly kept over
  initial pending/dispatched rows

Closes: backlog item #11 (Delegations list endpoint mismatch)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 14:22:39 +00:00
2 changed files with 229 additions and 20 deletions
@@ -745,6 +745,19 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
// delegation state by folding activity_logs rows by delegation_id.
// Kept for backward compatibility and for workspaces that never had
// DELEGATION_LEDGER_WRITE=1 during their delegation lifecycle.
//
// FIX: Deduplicates by delegation_id to avoid returning both the initial
// 'delegate' row (status=pending/in_progress) and the terminal
// 'delegate_result' row (status=completed/failed) for the same delegation.
// Without deduplication the agent's lazy refresh (_refresh_queued_from_platform)
// sees the initial row first (still status=pending/in_progress) and never
// discovers the terminal outcome even though a matching delegate_result row
// exists in the result set.
//
// Deduplication strategy: Go-side map keyed by delegation_id. For each
// delegation_id the newest row (by created_at) is kept. Rows with an empty
// delegation_id cannot be deduplicated (pre-#318 records lack the field) and
// are returned as-is without deduplication.
func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context, workspaceID string) []map[string]interface{} {
rows, err := db.DB.QueryContext(ctx, `
SELECT id, activity_type, COALESCE(source_id::text, ''), COALESCE(target_id::text, ''),
@@ -763,23 +776,50 @@ func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context,
defer rows.Close()
var result []map[string]interface{}
// Deduplicate by delegation_id: keep the newest (first in DESC order).
// Rows with an empty delegation_id (pre-#318) are kept as-is.
seen := make(map[string]map[string]interface{})
for rows.Next() {
var id, actType, sourceID, targetID, summary, status, errorDetail, responseBody, delegationID string
var createdAt time.Time
if err := rows.Scan(&id, &actType, &sourceID, &targetID, &summary, &status, &errorDetail, &responseBody, &delegationID, &createdAt); err != nil {
continue
}
entry := map[string]interface{}{
"id": id,
"type": actType,
"source_id": sourceID,
"target_id": targetID,
"summary": summary,
"status": status,
"created_at": createdAt,
// Rows are ordered by created_at DESC; the first occurrence per
// delegation_id is the newest — skip later (older) duplicates.
if delegationID == "" {
// No delegation_id: cannot correlate, return as standalone.
// These are pre-#318 rows that lack delegation_id.
entry := map[string]interface{}{
"id": id,
"type": actType,
"source_id": sourceID,
"target_id": targetID,
"summary": summary,
"status": status,
"created_at": createdAt,
}
if errorDetail != "" {
entry["error"] = errorDetail
}
if responseBody != "" {
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
}
result = append(result, entry)
continue
}
if delegationID != "" {
entry["delegation_id"] = delegationID
if _, exists := seen[delegationID]; exists {
continue // older duplicate, skip
}
entry := map[string]interface{}{
"delegation_id": delegationID,
"id": id,
"type": actType,
"source_id": sourceID,
"target_id": targetID,
"summary": summary,
"status": status,
"created_at": createdAt,
}
if errorDetail != "" {
entry["error"] = errorDetail
@@ -787,12 +827,16 @@ func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context,
if responseBody != "" {
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
}
result = append(result, entry)
seen[delegationID] = entry
}
if err := rows.Err(); err != nil {
log.Printf("ListDelegations rows.Err: %v", err)
}
// Append deduplicated entries (those with a delegation_id).
for _, entry := range seen {
result = append(result, entry)
}
if result == nil {
return []map[string]interface{}{}
}
@@ -273,7 +273,7 @@ func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).AddRow(
"act-1", "delegate",
"act-1", "delegation",
"ws-1", "ws-2",
"analyse Q1 numbers",
"in_progress",
@@ -296,8 +296,8 @@ func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
if e["id"] != "act-1" {
t.Errorf("id: got %v, want act-1", e["id"])
}
if e["type"] != "delegate" {
t.Errorf("type: got %v, want delegate", e["type"])
if e["type"] != "delegation" {
t.Errorf("type: got %v, want delegation", e["type"])
}
if e["source_id"] != "ws-1" {
t.Errorf("source_id: got %v, want ws-1", e["source_id"])
@@ -331,9 +331,9 @@ func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).AddRow(
"act-2", "delegate_result",
"act-2", "delegation",
"ws-1", "ws-2",
"result summary",
"Delegation failed",
"failed",
"Callee workspace not reachable",
`{"text":"the result body text"}`,
@@ -353,8 +353,8 @@ func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if e["type"] != "delegate_result" {
t.Errorf("type: got %v", e["type"])
if e["type"] != "delegation" {
t.Errorf("type: got %v, want delegation", e["type"])
}
if e["error"] != "Callee workspace not reachable" {
t.Errorf("error: got %v", e["error"])
@@ -417,8 +417,8 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now).
AddRow("act-2", "delegate", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
AddRow("act-1", "delegation", "ws-1", "ws-2", "task", "queued", "", "", "", now).
AddRow("act-2", "delegation", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
RowError(1, context.DeadlineExceeded)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
@@ -445,3 +445,168 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
// sqlmock.NewRows([]string{}).AddRow(...) to panic in test SETUP. The handler
// has no recover(), so a scan panic would crash the process — the correct
// behaviour. Real-DB integration tests cover this path.
// ---------- Deduplication by delegation_id ----------
// TestListDelegationsFromActivityLogs_DeduplicationKeepsNewest verifies that when
// both the initial 'delegate' row and the terminal 'delegate_result' row exist for
// the same delegation_id, only one entry (the newest) is returned. This is the
// fix for the double-row artifact where the agent's _refresh_queued_from_platform
// would scan the stale initial row (status=in_progress) before reaching the
// terminal row (status=completed/failed).
func TestListDelegationsFromActivityLogs_DeduplicationKeepsNewest(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
t0 := time.Now().Add(-2 * time.Hour)
t1 := time.Now().Add(-1 * time.Hour)
// Rows returned in created_at DESC order. The query uses DISTINCT ON
// (delegation_id) ORDER BY delegation_id, created_at DESC, so the newest
// row per delegation_id is returned.
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
// delegate_result row (newest for del-abc) — should be kept
AddRow("act-2", "delegation", "ws-1", "ws-2",
"Delegation completed", "completed", "",
`{"text":"the answer is 42"}`, "del-abc", t1).
// delegate row (oldest for del-abc) — should be dropped by DISTINCT ON
AddRow("act-1", "delegation", "ws-1", "ws-2",
"Delegating to ws-2", "in_progress", "",
"", "del-abc", t0)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry after deduplication, got %d: %v", len(got), got)
}
e := got[0]
if e["delegation_id"] != "del-abc" {
t.Errorf("delegation_id: got %v, want del-abc", e["delegation_id"])
}
if e["status"] != "completed" {
t.Errorf("status: got %v, want completed (newest row kept)", e["status"])
}
if e["response_preview"] != `{"text":"the answer is 42"}` {
t.Errorf("response_preview: got %v", e["response_preview"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromActivityLogs_DeduplicationDistinctDelegations verifies that
// rows with different delegation_ids are all returned (deduplication is per-id, not global).
func TestListDelegationsFromActivityLogs_DeduplicationDistinctDelegations(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
now := time.Now()
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
AddRow("act-a", "delegation", "ws-1", "ws-2", "task a", "completed", "", "", "del-A", now).
AddRow("act-b", "delegation", "ws-1", "ws-3", "task b", "failed", "timeout", "", "del-B", now).
AddRow("act-c", "delegation", "ws-1", "ws-4", "task c", "in_progress", "", "", "del-C", now)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 3 {
t.Fatalf("expected 3 entries (all distinct delegation_ids), got %d", len(got))
}
seen := make(map[string]bool)
for _, e := range got {
id := e["delegation_id"].(string)
if seen[id] {
t.Errorf("duplicate delegation_id %q in result", id)
}
seen[id] = true
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromActivityLogs_DeduplicationMixedTerminalStatuses verifies that
// a failed delegate_result row (newest) is kept over the initial in_progress row,
// and a completed delegate_result is kept over the initial pending row.
func TestListDelegationsFromActivityLogs_DeduplicationMixedTerminalStatuses(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
tOld := time.Now().Add(-3 * time.Hour)
tMid := time.Now().Add(-2 * time.Hour)
tNew := time.Now().Add(-1 * time.Hour)
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
// del-X: newest is completed → keep completed
AddRow("act-x2", "delegation", "ws-1", "ws-2", "task X done", "completed", "", `{"text":"X result"}`, "del-X", tNew).
AddRow("act-x1", "delegation", "ws-1", "ws-2", "Delegating to ws-2", "pending", "", "", "del-X", tOld).
// del-Y: newest is failed → keep failed
AddRow("act-y2", "delegation", "ws-1", "ws-3", "task Y done", "failed", "network error", "", "del-Y", tMid).
AddRow("act-y1", "delegation", "ws-1", "ws-3", "Delegating to ws-3", "dispatched", "", "", "del-Y", tOld)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 2 {
t.Fatalf("expected 2 entries after deduplication, got %d: %v", len(got), got)
}
byID := make(map[string]map[string]interface{})
for _, e := range got {
byID[e["delegation_id"].(string)] = e
}
x := byID["del-X"]
if x["status"] != "completed" {
t.Errorf("del-X: got status %v, want completed", x["status"])
}
y := byID["del-Y"]
if y["status"] != "failed" {
t.Errorf("del-Y: got status %v, want failed", y["status"])
}
if y["error"] != "network error" {
t.Errorf("del-Y: got error %v, want 'network error'", y["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}