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
19 changes: 17 additions & 2 deletions client/src/components/apps/tabs/AutomationTab.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { RefreshCw, Play, PauseCircle, Settings, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import { RefreshCw, Play, PauseCircle, Settings, ChevronDown, ChevronRight, Sparkles, AlertTriangle } from 'lucide-react';
import toast from '../../ui/Toast';
import BrailleSpinner from '../../BrailleSpinner';
import CronInput from '../../CronInput';
import ToggleSwitch from '../../ToggleSwitch';
import AppProviderPin from '../../cos/AppProviderPin';
import * as api from '../../../services/api';
import { AGENT_OPTIONS, hasProviderPin, toggleAppMetadataOverride, agentOptionButtonClass } from '../../cos/constants';
import { AGENT_OPTIONS, hasProviderPin, providerPinDivergesFromSchedule, toggleAppMetadataOverride, agentOptionButtonClass } from '../../cos/constants';
import { isCronExpression, describeCron } from '../../../utils/cronHelpers';
import { PROVIDER_TYPES, providerDisplayName } from '../../../utils/providers';
import CustomTasksSection from './CustomTasksSection';
Expand Down Expand Up @@ -251,6 +251,12 @@ export default function AutomationTab({ appId, appName }) {
const effectiveProviderName = override.providerId
? providerDisplayName(providers, override.providerId)
: taskProviderName;
// Surfaced collapsed (not just inside Configure): an app-level pin
// silently wins over the Schedule pin at spawn (#4783), so a task
// schedule showing one provider while this app's override names a
// DIFFERENT one is exactly the "why did it run somewhere else"
// confusion a user hits with only the Schedule page open.
const providerDivergesFromSchedule = providerPinDivergesFromSchedule(override, globalConfig);

return (
<div key={taskType} className="bg-port-card border border-port-border rounded-lg p-3 space-y-2">
Expand All @@ -276,6 +282,15 @@ export default function AutomationTab({ appId, appName }) {
</span>
<div className="flex-1 min-w-0">
<span className="text-white font-mono text-xs">{taskType}</span>
{providerDivergesFromSchedule && (
<span
className="inline-flex items-center gap-1 ml-2 text-port-warning"
title={`Runs on ${effectiveProviderName} — the schedule's default is ${taskProviderName}, but this app's provider override wins`}
>
<AlertTriangle size={11} />
<span className="text-[10px] uppercase tracking-wide">Provider override</span>
</span>
)}
<div className="text-xs text-gray-500">{effectiveLabel}{intervalSuffix}</div>
</div>
<button
Expand Down
21 changes: 21 additions & 0 deletions client/src/components/apps/tabs/AutomationTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ describe('AutomationTab per-app options', () => {
expect(within(row).getByLabelText('Provider override')).toBeInTheDocument();
});

it('flags a provider override that diverges from the schedule pin, collapsed', async () => {
// Schedule pins layered-intelligence to 'global-claude'; the app overrides
// it to a DIFFERENT provider — the exact silent-shadowing scenario #4783
// documents. Must be visible without expanding Configure.
await renderTab({ 'layered-intelligence': { providerId: 'claude-cli' } });
const row = rowFor('layered-intelligence');
expect(within(row).getByText('Provider override')).toBeInTheDocument();
});

it('does not flag an override that matches the schedule pin', async () => {
await renderTab({ 'layered-intelligence': { providerId: 'global-claude' } });
const row = rowFor('layered-intelligence');
expect(within(row).queryByText('Provider override')).toBeNull();
});

it('does not flag a task type with no app override', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
expect(within(row).queryByText('Provider override')).toBeNull();
});

it('changing the provider PATCHes updateAppTaskTypeOverride with providerId + cleared model', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
Expand Down
12 changes: 12 additions & 0 deletions client/src/components/cos/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,18 @@ export function hasProviderPin(override) {
return !!(override?.providerId || override?.model);
}

// Whether an app's per-task provider pin NAMES A DIFFERENT PROVIDER than the
// task's own Schedule pin. The app pin always wins at spawn (#4783), so this is
// the "silently overrides what the Schedule page shows" case worth flagging —
// not merely "an override exists" (hasProviderPin above): an app pin that
// happens to match the schedule, or a schedule with no pin of its own, isn't a
// surprise. Both sides must actually name a provider; an unset schedule pin
// (any override "diverges" from nothing) or an unset app pin (nothing to
// diverge) are not divergence.
export function providerPinDivergesFromSchedule(override, globalConfig) {
return !!(override?.providerId && globalConfig?.providerId && override.providerId !== globalConfig.providerId);
}

// Compute new taskMetadata after toggling a field in a per-app override.
// Returns null when all overrides are cleared (inherit everything).
// Enforces invariant: openPR implies useWorktree (turning on openPR forces
Expand Down
25 changes: 24 additions & 1 deletion client/src/components/cos/constants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
healthIssueTone,
fresherHealth,
providerPinPatch,
hasProviderPin
hasProviderPin,
providerPinDivergesFromSchedule
} from './constants';

// These mirror the server's domainBudgets/domainAutonomy helpers so the UI's
Expand Down Expand Up @@ -333,6 +334,28 @@ describe('hasProviderPin', () => {
});
});

describe('providerPinDivergesFromSchedule', () => {
it('is true only when both sides name a provider AND they differ', () => {
expect(providerPinDivergesFromSchedule(
{ providerId: 'claude-ollama-tui' }, { providerId: 'fleet-gpu---opencode-tui' }
)).toBe(true);
});

it('is false when the app pin matches the schedule pin', () => {
expect(providerPinDivergesFromSchedule({ providerId: 'claude-cli' }, { providerId: 'claude-cli' })).toBe(false);
});

it('is false when the app has no pin of its own', () => {
expect(providerPinDivergesFromSchedule({}, { providerId: 'claude-cli' })).toBe(false);
expect(providerPinDivergesFromSchedule(undefined, { providerId: 'claude-cli' })).toBe(false);
});

it('is false when the schedule has no pin to diverge from', () => {
expect(providerPinDivergesFromSchedule({ providerId: 'claude-cli' }, {})).toBe(false);
expect(providerPinDivergesFromSchedule({ providerId: 'claude-cli' }, undefined)).toBe(false);
});
});

// @vitest-environment node

// REVIEWER_OPTIONS is UI copy DERIVED from the roster in `lib/reviewerPins.js`,
Expand Down