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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-18 13:04:54 -07:00
parent 0d538ab27a
commit 2f36bb9a7f
3 changed files with 31 additions and 0 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -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;