Merge branch 'staging' into fix/coverage-gate-platform-go-1823
This commit is contained in:
commit
bbc59fccf8
@ -451,16 +451,17 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{})
|
||||
}
|
||||
|
||||
// Auto-recovery: if a workspace is marked "failed" or "provisioning" but is
|
||||
// actively sending heartbeats, it has clearly booted successfully. Transition
|
||||
// to "online" so the scheduler and dashboard reflect reality. This catches
|
||||
// cases where the provisioner crashed mid-setup or an earlier error left the
|
||||
// status stale.
|
||||
if currentStatus == "failed" || currentStatus == "provisioning" {
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'online', updated_at = now() WHERE id = $1 AND status IN ('failed', 'provisioning')`, payload.WorkspaceID); err != nil {
|
||||
log.Printf("Heartbeat: failed to auto-recover %s from %s to online: %v", payload.WorkspaceID, currentStatus, err)
|
||||
// Auto-recovery: if a workspace is marked "provisioning" but is actively sending
|
||||
// heartbeats, it has successfully started up. Transition to "online" so the scheduler
|
||||
// and A2A proxy can dispatch tasks to it. The provisioner does not call
|
||||
// /registry/register on container start — only the heartbeat loop does, so this
|
||||
// transition is the only mechanism that moves newly-started workspaces out of
|
||||
// the phantom-idle state. (#1784)
|
||||
if currentStatus == "provisioning" {
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'online', updated_at = now() WHERE id = $1 AND status = 'provisioning'`, payload.WorkspaceID); err != nil {
|
||||
log.Printf("Heartbeat: failed to transition %s from provisioning to online: %v", payload.WorkspaceID, err)
|
||||
} else {
|
||||
log.Printf("Heartbeat: auto-recovered %s from %s to online (heartbeat received)", payload.WorkspaceID, currentStatus)
|
||||
log.Printf("Heartbeat: transitioned %s from provisioning to online (heartbeat received)", payload.WorkspaceID)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{
|
||||
"recovered_from": currentStatus,
|
||||
|
||||
@ -134,6 +134,56 @@ func TestHeartbeatHandler_OfflineToOnline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Heartbeat — provisioning → online recovery (#1784) ====================
|
||||
|
||||
func TestHeartbeatHandler_ProvisioningToOnline(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Expect prevTask SELECT
|
||||
mock.ExpectQuery("SELECT COALESCE\\(current_task").
|
||||
WithArgs("ws-provisioning").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow(""))
|
||||
|
||||
// Expect heartbeat UPDATE
|
||||
mock.ExpectExec("UPDATE workspaces SET").
|
||||
WithArgs("ws-provisioning", 0.0, "", 1, 3000, "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect evaluateStatus SELECT — currently provisioning
|
||||
mock.ExpectQuery("SELECT status FROM workspaces WHERE id =").
|
||||
WithArgs("ws-provisioning").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("provisioning"))
|
||||
|
||||
// Expect status transition to online (#1784)
|
||||
mock.ExpectExec("UPDATE workspaces SET status = 'online'").
|
||||
WithArgs("ws-provisioning").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect RecordAndBroadcast INSERT for WORKSPACE_ONLINE
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"workspace_id":"ws-provisioning","error_rate":0.0,"sample_error":"","active_tasks":1,"uptime_seconds":3000}`
|
||||
c.Request = httptest.NewRequest("POST", "/registry/heartbeat", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Heartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatHandler_BadJSON(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user