Skip to content
Merged
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
7 changes: 7 additions & 0 deletions platform/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ func main() {
cronSched := scheduler.New(wh, broadcaster)
go supervised.RunWithRecover(ctx, "scheduler", cronSched.Start)

// Hibernation Monitor — auto-pauses idle workspaces that have
// hibernation_idle_minutes configured (#711). Wakeup is triggered
// automatically on the next incoming A2A message.
go supervised.RunWithRecover(ctx, "hibernation-monitor", func(c context.Context) {
registry.StartHibernationMonitor(c, wh.HibernateWorkspace)
})

// Channel Manager — social channel integrations (Telegram, Slack, etc.)
channelMgr := channels.NewManager(wh, broadcaster)
go supervised.RunWithRecover(ctx, "channel-manager", channelMgr.Start)
Expand Down
32 changes: 24 additions & 8 deletions platform/internal/handlers/a2a_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,16 +274,16 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
}
defer resp.Body.Close()

// Read agent response (capped at 10MB)
// Read agent response (capped at 10MB).
// #689: Do() succeeded, which means the target received the request and sent
// back response headers — delivery is confirmed. The body couldn't be
// fully read (connection drop, timeout mid-stream). Surface
// delivery_confirmed so callers can distinguish "not delivered" from
// "delivered, but response body lost". When delivery is confirmed,
// log the activity as successful (delivery happened) rather than leaving
// a false "failed" entry in the audit trail.
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxProxyResponseBody))
if readErr != nil {
// Do() succeeded, which means the target received the request and sent
// back response headers — delivery is confirmed. The body couldn't be
// fully read (connection drop, timeout mid-stream). Surface
// delivery_confirmed so callers can distinguish "not delivered" from
// "delivered, but response body lost" (#689). When delivery is confirmed,
// log the activity as successful (delivery happened) rather than leaving
// a false "failed" entry in the audit trail.
deliveryConfirmed := resp.StatusCode >= 200 && resp.StatusCode < 400
log.Printf("ProxyA2A: body read failed for %s (status=%d delivery_confirmed=%v bytes_read=%d): %v",
workspaceID, resp.StatusCode, deliveryConfirmed, len(respBody), readErr)
Expand Down Expand Up @@ -338,6 +338,22 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
}
}
if !urlNullable.Valid || urlNullable.String == "" {
// Auto-wake hibernated workspace on incoming A2A message (#711).
// Re-provision asynchronously and return 503 with a retry hint so
// the caller can retry once the workspace is back online (~10s).
if status == "hibernated" {
log.Printf("ProxyA2A: waking hibernated workspace %s", workspaceID)
go h.RestartByID(workspaceID)
return "", &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": "15"},
Response: gin.H{
"error": "workspace is waking from hibernation — retry in ~15 seconds",
"waking": true,
"retry_after": 15,
},
}
}
return "", &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{"error": "workspace has no URL", "status": status},
Expand Down
78 changes: 78 additions & 0 deletions platform/internal/handlers/a2a_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1237,3 +1237,81 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
handler.logA2ASuccess(context.Background(), "ws-err", "ws-caller", []byte(`{}`), []byte(`{}`), "message/send", 500, 10)
time.Sleep(80 * time.Millisecond)
}

// ──────────────────────────────────────────────────────────────────────────────
// A2A auto-wake: hibernated workspace (#711)
// ──────────────────────────────────────────────────────────────────────────────

// TestResolveAgentURL_HibernatedWorkspace_Returns503WithWaking verifies the
// auto-wake path added in PR #724: when resolveAgentURL finds a workspace with
// status='hibernated' and no URL, it must:
// - Return a proxyA2AError with Status 503
// - Set Retry-After: 15 in Headers
// - Include waking:true and retry_after:15 in the response body
//
// RestartByID fires asynchronously via `go h.RestartByID(workspaceID)`. Because
// provisioner is nil in tests, RestartByID returns immediately without any DB
// calls, so no additional mocks are needed.
func TestResolveAgentURL_HibernatedWorkspace_Returns503WithWaking(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t) // empty Redis → GetCachedURL returns error → DB fallback

handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())

// DB fallback: workspace exists but has no URL and is hibernated.
mock.ExpectQuery(`SELECT url, status FROM workspaces WHERE id =`).
WithArgs("ws-hibernated").
WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow("", "hibernated"))

_, perr := handler.resolveAgentURL(context.Background(), "ws-hibernated")

if perr == nil {
t.Fatal("expected proxyA2AError, got nil")
}
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("expected status 503, got %d", perr.Status)
}
if perr.Headers["Retry-After"] != "15" {
t.Errorf("expected Retry-After: 15, got %q", perr.Headers["Retry-After"])
}

if perr.Response["waking"] != true {
t.Errorf("expected waking:true in body, got %v", perr.Response["waking"])
}
if perr.Response["retry_after"] != 15 {
t.Errorf("expected retry_after:15 in body, got %v", perr.Response["retry_after"])
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations: %v", err)
}
}

// TestResolveAgentURL_HibernatedWorkspace_NullURLVariant verifies the same
// auto-wake behaviour when the DB returns a SQL NULL for the url column
// (rather than an empty string). Both forms represent "no URL assigned".
func TestResolveAgentURL_HibernatedWorkspace_NullURLVariant(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())

mock.ExpectQuery(`SELECT url, status FROM workspaces WHERE id =`).
WithArgs("ws-hibernated-null").
WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow(nil, "hibernated"))

_, perr := handler.resolveAgentURL(context.Background(), "ws-hibernated-null")

if perr == nil {
t.Fatal("expected proxyA2AError, got nil")
}
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("expected status 503, got %d", perr.Status)
}
if perr.Headers["Retry-After"] != "15" {
t.Errorf("expected Retry-After: 15, got %q", perr.Headers["Retry-After"])
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations: %v", err)
}
}
Loading
Loading