From 2f36bb9a7f2dbcce81e608a3aa6b193519f55285 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 18 Apr 2026 13:04:54 -0700 Subject: [PATCH] feat(platform): track last_outbound_at for silent-workspace detection (closes #817) Sub of #795 (phantom-busy post-mortem). Adds last_outbound_at TIMESTAMPTZ column to workspaces. Bumped async on every successful outbound A2A call from a real workspace (skip canvas + system callers). Exposed in GET /workspaces/:id response as "last_outbound_at". PM/Dev Lead orchestrators can now detect workspaces that have gone silent despite being online (> 2h + active cron = phantom-busy warning). Co-Authored-By: Claude Opus 4.6 (1M context) --- workspace-server/internal/handlers/a2a_proxy.go | 14 ++++++++++++++ workspace-server/internal/handlers/workspace.go | 12 ++++++++++++ .../034_workspaces_last_outbound_at.up.sql | 5 +++++ 3 files changed, 31 insertions(+) create mode 100644 workspace-server/migrations/034_workspaces_last_outbound_at.up.sql diff --git a/workspace-server/internal/handlers/a2a_proxy.go b/workspace-server/internal/handlers/a2a_proxy.go index ca81334b..4d57b6c9 100644 --- a/workspace-server/internal/handlers/a2a_proxy.go +++ b/workspace-server/internal/handlers/a2a_proxy.go @@ -591,6 +591,20 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle if wsNameForLog == "" { wsNameForLog = workspaceID } + + // #817: track outbound activity on the CALLER so orchestrators can detect + // silent workspaces. Only update when callerID is a real workspace (not + // canvas, not a system caller) and the target returned 2xx/3xx. + if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 { + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := db.DB.ExecContext(bgCtx, + `UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil { + log.Printf("last_outbound_at update failed for %s: %v", callerID, err) + } + }() + } summary := a2aMethod + " → " + wsNameForLog go func(parent context.Context) { logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second) diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index a56f2dfc..63b5d731 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -442,6 +442,18 @@ func (h *WorkspaceHandler) Get(c *gin.Context) { delete(ws, "budget_limit") delete(ws, "monthly_spend") + // #817: expose last_outbound_at so orchestrators can detect silent + // workspaces. Non-sensitive — just a timestamp of the most recent + // outbound A2A. Null if the workspace has never sent anything. + var lastOutbound sql.NullTime + if err := db.DB.QueryRowContext(c.Request.Context(), + `SELECT last_outbound_at FROM workspaces WHERE id = $1`, id, + ).Scan(&lastOutbound); err == nil && lastOutbound.Valid { + ws["last_outbound_at"] = lastOutbound.Time + } else { + ws["last_outbound_at"] = nil + } + c.JSON(http.StatusOK, ws) } diff --git a/workspace-server/migrations/034_workspaces_last_outbound_at.up.sql b/workspace-server/migrations/034_workspaces_last_outbound_at.up.sql new file mode 100644 index 00000000..eff52391 --- /dev/null +++ b/workspace-server/migrations/034_workspaces_last_outbound_at.up.sql @@ -0,0 +1,5 @@ +-- Issue #817 (sub of #795): track last outbound A2A activity per workspace so +-- PM/Dev Lead can detect workspaces that have gone silent despite being online. +-- The orchestrator compares this against now() in its pulse; > 2 hours with an +-- active cron triggers a phantom-busy warning. +ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS last_outbound_at TIMESTAMPTZ;