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
5 changes: 2 additions & 3 deletions api/pkg/quota/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
external_agent "github.com/helixml/helix/api/pkg/external-agent"
"github.com/helixml/helix/api/pkg/store"
"github.com/helixml/helix/api/pkg/types"
"github.com/stripe/stripe-go/v76"
)

type QuotaManager interface {
Expand Down Expand Up @@ -61,7 +60,7 @@ func (m *DefaultQuotaManager) getOrgQuotas(ctx context.Context, orgID string) (*
MaxSpecTasks: -1,
}
// Active subscription
case wallet.StripeSubscriptionID != "" && wallet.SubscriptionStatus == stripe.SubscriptionStatusActive:
case wallet.StripeSubscriptionID != "" && wallet.IsSubscriptionActive():
// Paid plan limits
quotas = m.getProQuotas()
default:
Expand Down Expand Up @@ -131,7 +130,7 @@ func (m *DefaultQuotaManager) getUserQuotas(ctx context.Context, userID string)
MaxRepositories: -1,
MaxSpecTasks: -1,
}
case wallet.StripeSubscriptionID != "" && wallet.SubscriptionStatus == stripe.SubscriptionStatusActive:
case wallet.StripeSubscriptionID != "" && wallet.IsSubscriptionActive():
// Paid plan limits
quotas = m.getProQuotas()
default:
Expand Down
21 changes: 21 additions & 0 deletions api/pkg/quota/quota_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ func proWallet(userID string) *types.Wallet {
}
}

func trialingWallet(userID string) *types.Wallet {
return &types.Wallet{
UserID: userID,
StripeSubscriptionID: "sub_trial",
SubscriptionStatus: stripe.SubscriptionStatusTrialing,
}
}

func orgFreeWallet(orgID string) *types.Wallet {
return &types.Wallet{OrgID: orgID}
}
Expand Down Expand Up @@ -140,6 +148,19 @@ func (s *QuotaManagerSuite) TestGetQuotas_UserProQuotas() {
s.Equal(10000, resp.MaxSpecTasks)
}

// A trialing subscription must grant Pro quotas, not Free. Regression test for
// the bug where the quota path checked `status == active` exactly, so trialing
// orgs were silently throttled to Free limits (e.g. 2 concurrent desktops).
func (s *QuotaManagerSuite) TestGetQuotas_UserTrialingGetsProQuotas() {
s.expectUserQuotaDefaults("user1", trialingWallet("user1"), enforceQuotasSettings())

resp, err := s.manager.GetQuotas(context.Background(), &types.QuotaRequest{UserID: "user1"})
s.NoError(err)

s.Equal(5, resp.MaxConcurrentDesktops, "trialing should get Pro desktop limit, not Free")
s.Equal(20, resp.MaxProjects)
}

func (s *QuotaManagerSuite) TestGetQuotas_UserQuotasDisabled() {
s.expectUserQuotaDefaults("user1", freeWallet("user1"), disabledQuotasSettings())

Expand Down
30 changes: 30 additions & 0 deletions api/pkg/server/auto_wake_stuck_interactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,36 @@ func (apiServer *HelixAPIServer) maybeKickColdStart(ctx context.Context, stuck *
session = nil
}

// A desktop-quota block is NOT a transient cold-start — retrying can't help,
// and the generic "agent never connected" banner hides the real reason. If
// the session's org is at its concurrent-desktop limit (and quotas are
// enforced), surface the actual limit to the user immediately and stop.
// Mirrors hydra_executor.checkLimits (gated on EnforceQuotas).
if session != nil && apiServer.quotaManager != nil {
if settings, sErr := apiServer.Store.GetSystemSettings(ctx); sErr == nil && settings.EnforceQuotas {
if resp, qErr := apiServer.quotaManager.LimitReached(ctx, &types.QuotaLimitReachedRequest{
UserID: session.Owner,
OrganizationID: session.OrganizationID,
Resource: types.ResourceDesktop,
}); qErr == nil && resp != nil && resp.LimitReached {
stuck.State = types.InteractionStateError
stuck.Error = fmt.Sprintf("Desktop limit reached (%d). Stop a running desktop session, or raise your organization's concurrent-desktop limit, then retry.", resp.Limit)
stuck.Updated = time.Now()
stuck.Completed = time.Now()
if _, uErr := apiServer.Store.UpdateInteraction(ctx, stuck); uErr != nil {
log.Warn().Err(uErr).Str("interaction_id", stuck.ID).Msg("[AUTO_WAKE] Failed to mark interaction errored for desktop limit")
return
}
if _, clearErr := apiServer.Store.ClearSessionStartingStatus(ctx, stuck.SessionID); clearErr != nil {
log.Warn().Err(clearErr).Str("session_id", stuck.SessionID).Msg("[AUTO_WAKE] Failed to clear starting status after surfacing desktop limit")
}
log.Warn().Str("interaction_id", stuck.ID).Str("session_id", stuck.SessionID).Int("limit", resp.Limit).
Msg("[AUTO_WAKE] Desktop limit reached — surfaced to user, not retrying cold-start")
return
}
}
}

// Skip if a container boot is genuinely in progress and we're still
// inside the grace period. Both "starting" and "running" count as
// in-progress here: see the function header for why the post-bridge,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/dashboard/ActivateTrialDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const ActivateTrialDialog: FC<ActivateTrialDialogProps> = ({ open, onClose, user
}}
>
<Typography variant="h6" component="div">
Activate Trial
Activate
</Typography>
<IconButton aria-label="close" onClick={handleClose} disabled={activateTrial.isPending}>
<CloseIcon />
Expand Down Expand Up @@ -146,7 +146,7 @@ const ActivateTrialDialog: FC<ActivateTrialDialogProps> = ({ open, onClose, user
disabled={activateTrial.isPending}
startIcon={activateTrial.isPending ? <CircularProgress size={20} /> : null}
>
{activateTrial.isPending ? 'Activating…' : 'Activate Trial'}
{activateTrial.isPending ? 'Activating…' : 'Activate'}
</Button>
</DialogActions>
</Dialog>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/dashboard/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ const UsersTable: FC<UsersTableProps> = ({ onSelectUser }) => {
{isCloud && !trialActiveOrStashed(menuUser) && (
<MenuItem onClick={handleActivateTrial}>
<ListItemIcon><CardGiftcardIcon fontSize="small" /></ListItemIcon>
<ListItemText>Activate trial</ListItemText>
<ListItemText>Activate</ListItemText>
</MenuItem>
)}
{isCloud && trialActiveOrStashed(menuUser) && (
Expand Down
Loading