From c5c604299ea4548c382917585aaeaaa9bba21e90 Mon Sep 17 00:00:00 2001
From: "[._.]/ Adam Eivy"
Date: Fri, 4 Sep 2026 05:16:51 +0000
Subject: [PATCH 1/3] show which reviewers a manual claim will actually run,
not the install defaults
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A `/do:next` claim resolves its reviewers from the claim-work task metadata
FIRST and only falls back to the install-wide Code Review Defaults. Both manual
claim surfaces seeded their reviewer display from `GET /api/code-review/defaults`
alone, which cannot see that override — so a claim-work pin saved months earlier
kept running codex + claude while every reviewer control on screen showed the
antigravity chain the user had since configured. The Issues tab, which offers no
reviewer picker at all, showed nothing.
- New `GET /api/apps/:id/claim-reviewers` resolves the chain through the same
`resolveClaimWorkMetadata` → `resolveClaimReviewerConfig` path
`buildClaimWorkTask` uses to fill the prompt's `{reviewers}` token, and reports
`source` (`task-override` vs `defaults`) so the UI can name the layer that won.
- `useClaimReviewers` backs both surfaces. A failed lookup stays unresolved
rather than reporting an empty chain — "couldn't ask" must not read as "merges
with no review".
- The run drawer seeds its untouched picker from that resolution and, on an
override, points at Chief of Staff → Schedule rather than Models → Code
Reviewers. The Issues tab renders the same read-only summary beside its
provider pin.
- `REVIEWER_OVERRIDE_KEYS` / `hasReviewerOverride` replace the hand-listed roster
in GlobalConfigControls, so the picker's "Use system Code Review Defaults"
reset clears exactly what the server counts as an override; the client mirror
is pinned by the existing parity test.
---
.../src/components/apps/SlashDoRunDrawer.jsx | 52 ++++++++++---
.../components/apps/SlashDoRunDrawer.test.jsx | 36 +++++++++
client/src/components/apps/tabs/IssuesTab.jsx | 31 +++++++-
.../components/apps/tabs/IssuesTab.test.jsx | 43 +++++++++++
client/src/components/cos/constants.js | 1 +
.../tabs/schedule/GlobalConfigControls.jsx | 19 ++---
client/src/hooks/README.md | 1 +
client/src/hooks/index.js | 1 +
client/src/hooks/useClaimReviewers.js | 65 ++++++++++++++++
client/src/lib/reviewerPins.js | 19 +++++
client/src/services/apiApps.js | 11 +++
server/lib/apiRouteCatalog.generated.json | 8 ++
server/lib/reviewerConfig.js | 31 ++++++++
server/lib/reviewerConfig.test.js | 46 ++++++++++++
server/routes/apps/taskTypes.js | 44 ++++++++++-
server/routes/apps/taskTypes.test.js | 74 +++++++++++++++++++
16 files changed, 457 insertions(+), 25 deletions(-)
create mode 100644 client/src/hooks/useClaimReviewers.js
diff --git a/client/src/components/apps/SlashDoRunDrawer.jsx b/client/src/components/apps/SlashDoRunDrawer.jsx
index ccbab708d2..297445924a 100644
--- a/client/src/components/apps/SlashDoRunDrawer.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.jsx
@@ -7,6 +7,7 @@ import useProviderModels from '../../hooks/useProviderModels';
import useReviewerModelOptions from '../../hooks/useReviewerModelOptions';
import { reviewerModelsFromDefaults, reviewerEffortsFromDefaults } from '../../lib/reviewerModels';
import { CodeReviewDefaultsProvider, useCodeReviewDefaults } from '../../hooks/useCodeReviewDefaults';
+import useClaimReviewers from '../../hooks/useClaimReviewers';
import { isProcessProvider } from '../../utils/providers';
import WorkItemPicker from './WorkItemPicker';
import * as api from '../../services/api';
@@ -30,6 +31,10 @@ const enabledProcessProviderFilter = (p) => Boolean(p?.enabled) && isProcessProv
*/
function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, onQueued }) {
const codeReviewDefaults = useCodeReviewDefaults();
+ // What a claim actually resolves for this app — the claim-work override layer
+ // the defaults above cannot see. Only `/do:next` reads reviewers server-side,
+ // so no other command pays for the lookup.
+ const claimReviewers = useClaimReviewers(command === 'next' ? appId : null);
// Resolved model lists for the reviewer table's Model column (the picker never
// fetches — see its `modelOptions` prop).
const reviewerModelOptions = useReviewerModelOptions();
@@ -42,19 +47,37 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
const [effort, setEffort] = useState('');
const [simplify, setSimplify] = useState(true);
- // Seeded from the install's Code Review Defaults for display. `reviewDirty`
- // gates whether they're SENT — see the component doc.
+ // Seeded from what the RUN will resolve for display. `review` staying null is
+ // what gates whether the fields are SENT — see the component doc.
const [review, setReview] = useState(null);
+ // Seeded display for an untouched picker. It has to show the reviewers the run
+ // would resolve, which is NOT the Code Review Defaults: a claim resolves its
+ // claim-work task metadata first and only falls back to them, so an override
+ // there runs a chain this drawer previously never showed (a claim reviewed with
+ // `codex` while the picker displayed `antigravity`). `claimReviewers` is that
+ // resolution; the defaults remain the fallback for the window before it lands
+ // and for a lookup that failed — an unresolved lookup must not seed an empty
+ // chain, which would read as "no reviewers configured".
+ //
// The defaults carry per-reviewer models and efforts as `Model` /
// `Effort` scalars; the picker takes the token-keyed maps, so fold them
- // in for the seeded (untouched) display.
+ // in for that fallback.
const seededReview = useMemo(
- () => ({
- ...codeReviewDefaults,
- reviewerModels: reviewerModelsFromDefaults(codeReviewDefaults),
- reviewerEfforts: reviewerEffortsFromDefaults(codeReviewDefaults),
- }),
- [codeReviewDefaults]
+ () => (claimReviewers.resolved
+ ? {
+ reviewers: claimReviewers.reviewers,
+ usernames: claimReviewers.usernames,
+ optionalReviewers: claimReviewers.optionalReviewers,
+ reviewerMaxRounds: claimReviewers.reviewerMaxRounds,
+ reviewerModels: claimReviewers.reviewerModels,
+ reviewerEfforts: claimReviewers.reviewerEfforts,
+ }
+ : {
+ ...codeReviewDefaults,
+ reviewerModels: reviewerModelsFromDefaults(codeReviewDefaults),
+ reviewerEfforts: reviewerEffortsFromDefaults(codeReviewDefaults),
+ }),
+ [claimReviewers, codeReviewDefaults]
);
const reviewValue = review ?? seededReview;
@@ -171,6 +194,17 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
The claim flow opens and merges its own PR, so these reviewers gate that merge (slashdo --review-with).
{!review && ' Leave them untouched to use this app’s configured reviewers.'}
+ {/* Which layer supplied the seeded list. A claim-work override wins
+ over Models → Code Reviewers silently, so a user who changed the
+ install default and sees a different chain here needs to be told
+ where it came from — that mismatch is exactly what sent a claim to
+ `codex` after the defaults had been moved to `antigravity`. */}
+ {!review && claimReviewers.source === 'task-override' && (
+
+ These come from the claim-work task override in Chief of Staff → Schedule, not from
+ Models → Code Reviewers. Clear it there (“Use system Code Review Defaults”) to follow the install default again.
+
+ )}
>
)}
diff --git a/client/src/components/apps/SlashDoRunDrawer.test.jsx b/client/src/components/apps/SlashDoRunDrawer.test.jsx
index 2dfbc27bf8..7d451c8aa0 100644
--- a/client/src/components/apps/SlashDoRunDrawer.test.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.test.jsx
@@ -9,6 +9,9 @@ const api = vi.hoisted(() => ({
// Backs the reviewer table's Model column (useReviewerModelOptions).
getLocalLlmStatus: vi.fn(),
getAppWorkItems: vi.fn(),
+ // What the RUN resolves — the claim-work override layer getCodeReviewDefaults
+ // cannot see, and what the untouched picker is seeded from.
+ getAppClaimReviewers: vi.fn(),
createSlashdoTask: vi.fn()
}));
@@ -49,6 +52,10 @@ describe('SlashDoRunDrawer', () => {
reason: 'actionable-issues',
transient: false
});
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'defaults', reviewers: ['copilot'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'copilot'
+ });
api.createSlashdoTask.mockResolvedValue({ id: 'task-1', status: 'pending' });
});
@@ -70,6 +77,35 @@ describe('SlashDoRunDrawer', () => {
expect(settings.reviewers).toBeUndefined();
});
+ // The bug this seeding fixes: the picker used to display the Code Review
+ // Defaults, which do NOT include the claim-work task override the run resolves
+ // FIRST. A user who had moved the install default to `antigravity` saw
+ // `antigravity` here while every claim actually reviewed with codex + claude.
+ it('seeds the untouched picker from the reviewers the RUN resolves, not the install defaults', async () => {
+ api.getCodeReviewDefaults.mockResolvedValue({ reviewers: ['antigravity'], usernames: [], optionalReviewers: [] });
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'task-override', reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude'
+ });
+
+ renderDrawer();
+
+ // Selected reviewers render as Remove buttons; unselected ones as Add.
+ await waitFor(() => expect(screen.getByRole('button', { name: /Remove Codex/ })).toBeInTheDocument());
+ expect(screen.getByRole('button', { name: /Remove Claude/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Remove Antigravity/ })).not.toBeInTheDocument();
+ // …and the user is told WHERE that list comes from, since it isn't the panel
+ // they would go to in order to change it.
+ expect(screen.getByText(/claim-work/)).toBeInTheDocument();
+ });
+
+ it('does not blame a claim-work override when the reviewers came from the install defaults', async () => {
+ renderDrawer();
+
+ await waitFor(() => expect(screen.getByText('Reviewers (in order):')).toBeInTheDocument());
+ expect(screen.queryByText(/claim-work/)).not.toBeInTheDocument();
+ });
+
it('sends the reviewer list only once the user edits it', async () => {
const onQueued = vi.fn();
renderDrawer({ onQueued });
diff --git a/client/src/components/apps/tabs/IssuesTab.jsx b/client/src/components/apps/tabs/IssuesTab.jsx
index 572e0fc817..5ed74cd409 100644
--- a/client/src/components/apps/tabs/IssuesTab.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.jsx
@@ -12,6 +12,7 @@ import ProviderModelSelector from '../../ProviderModelSelector';
import { useThemeContext } from '../../ThemeContext';
import { useCosTaskUpdates } from '../../../hooks/useCosTaskUpdates';
import useProviderModels from '../../../hooks/useProviderModels';
+import useClaimReviewers from '../../../hooks/useClaimReviewers';
import { chipColors } from '../../../lib/chipContrast';
import { isProcessProvider } from '../../../utils/providers';
import * as api from '../../../services/api';
@@ -241,8 +242,10 @@ export default function IssuesTab({ appId, appName }) {
// Page-level provider/model/effort pin for every Claim AND Replan button on this tab —
// left untouched (blank), a claim resolves the install's active provider,
// same as the bare button always did (POST /tasks/slashdo -> resolveAgentProviderAndModel;
- // this manual path does NOT consult the app's scheduled claim-work override —
- // that's a separate resolution used only by the automated claim-work task).
+ // this manual path does NOT consult the app's scheduled claim-work override for
+ // the PROVIDER — that pin is read only by the automated claim-work task).
+ // Scoped to the provider deliberately: the REVIEWERS below do come from that
+ // override, which is precisely the mismatch `claimReviewers` exists to surface.
// This picker never persists across a reload; it's a session convenience for
// "claim the next several issues with model X" without reopening the Agent
// Operations drawer each time.
@@ -252,6 +255,13 @@ export default function IssuesTab({ appId, appName }) {
} = useProviderModels({ filter: enabledProcessProviderFilter, allowDefault: true, silent: true, withEffort: true });
const [effort, setEffort] = useState('');
const [overrideContext, setOverrideContext] = useState('');
+ // The reviewers a Claim launched from this tab will actually run. This tab
+ // offers no reviewer picker, so the resolution was previously invisible here —
+ // and it is NOT the Models → Code Reviewers list a user would assume: a
+ // claim-work task override wins over it, which is how a claim from this tab
+ // reviewed with `codex` after the install default had been changed. Surfaced
+ // read-only next to the provider pin, with its source named.
+ const claimReviewers = useClaimReviewers(appId);
// Keep the event-driven path based on the latest runs without putting a
// mutable state snapshot in its effect dependencies. Socket callbacks can
@@ -575,6 +585,23 @@ export default function IssuesTab({ appId, appName }) {
/>
+ {claimReviewers.resolved && (
+
+
+ Reviewed by
+
+
+ {claimReviewers.reviewers.length
+ ? {claimReviewers.csv || claimReviewers.reviewers.join(',')}
+ : 'No reviewers resolve for this app.'}
+ {claimReviewers.source === 'task-override'
+ ? <> — from the claim-work task override in{' '}
+ Chief of Staff → Schedule,
+ not Models → Code Reviewers. Clear it there to follow the install default.>
+ : <> — from Models → Code Reviewers.>}
+
+
+ )}
Override context or instructions (optional)
diff --git a/client/src/components/apps/tabs/IssuesTab.test.jsx b/client/src/components/apps/tabs/IssuesTab.test.jsx
index 7750c00c11..b069f5bf9b 100644
--- a/client/src/components/apps/tabs/IssuesTab.test.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.test.jsx
@@ -29,6 +29,9 @@ vi.mock('../../../services/api', () => ({
getAppIssues: vi.fn(),
createSlashdoTask: vi.fn(),
getProviders: vi.fn(),
+ // The tab reads the reviewers a claim will actually run so it can name them
+ // (and say whether a claim-work override supplied them).
+ getAppClaimReviewers: vi.fn(),
}));
import * as api from '../../../services/api';
@@ -77,6 +80,10 @@ beforeEach(() => {
api.getAppIssues.mockResolvedValue(okPayload([ISSUE]));
api.createSlashdoTask.mockResolvedValue({ id: 'task-1' });
api.getProviders.mockResolvedValue({ providers: [] });
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'defaults', reviewers: ['antigravity'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'antigravity',
+ });
});
afterEach(() => {
@@ -267,6 +274,42 @@ describe('IssuesTab', () => {
expect(screen.getByRole('link', { name: /Active/ })).toBeInTheDocument();
});
+ // This tab offers no reviewer picker, so before it named them the reviewer
+ // chain a Claim would run was invisible here — and it is not the Code
+ // Reviewers panel's list whenever a claim-work override exists. That gap sent
+ // claims to codex + claude for weeks after the install default had been moved.
+ it('names the reviewers a Claim will run, and blames the claim-work override that supplied them', async () => {
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'task-override', reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude',
+ });
+
+ await renderTab();
+
+ expect(await screen.findByText('codex,claude')).toBeInTheDocument();
+ expect(screen.getByText(/claim-work/)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Chief of Staff/ })).toHaveAttribute('href', '/cos/schedule');
+ });
+
+ it('points at the Code Reviewers panel when no override is in play', async () => {
+ await renderTab();
+
+ expect(await screen.findByText('antigravity')).toBeInTheDocument();
+ expect(screen.queryByText(/claim-work/)).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Code Reviewers/ })).toHaveAttribute('href', '/models/code-reviewers');
+ });
+
+ it('says nothing about reviewers when the lookup failed, rather than implying none are configured', async () => {
+ // "couldn't ask" and "nothing configured" must not collapse — an empty chain
+ // rendered here would read as a claim that merges with no review at all.
+ api.getAppClaimReviewers.mockRejectedValue(new Error('offline'));
+
+ await renderTab();
+
+ expect(await screen.findByText('Crash on save')).toBeInTheDocument();
+ expect(screen.queryByText('Reviewed by')).not.toBeInTheDocument();
+ });
+
it('sends the page-level provider/model/effort pin along with a claim', async () => {
api.getProviders.mockResolvedValue({
providers: [{
diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js
index b2ee6a756d..ba06b8bb02 100644
--- a/client/src/components/cos/constants.js
+++ b/client/src/components/cos/constants.js
@@ -335,6 +335,7 @@ export {
MAX_REVIEWER_MAX_ROUNDS,
REVIEW_STOP_MODES,
DEFAULT_REVIEW_STOP_MODE,
+ REVIEWER_OVERRIDE_KEYS,
normalizeReviewers,
MODEL_CAPABLE_CLI_REVIEWERS,
LOCAL_LLM_REVIEWERS,
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
index 9ca93ca0bd..4f125f53c0 100644
--- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
+++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
import useFieldDraft from '../../../../hooks/useFieldDraft';
import { RotateCcw, AlertCircle } from 'lucide-react';
import CronInput from '../../../CronInput';
-import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
+import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, REVIEWER_OVERRIDE_KEYS, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
import ReviewerPicker from '../../ReviewerPicker';
import Banner from '../../../ui/Banner';
import InfoTooltip from '../../../ui/InfoTooltip';
@@ -27,18 +27,11 @@ const PR_COMPLETION_INHERIT_HINT = `Uses the target app's "After opening PR" def
// These fields are the task-local reviewer-loop override. Removing them lets
// the picker and server resolver fall back to the install-wide Code Review
-// Defaults without changing the task's PR policy or other agent options.
-const REVIEW_CONFIG_KEYS = [
- 'reviewer',
- 'reviewers',
- 'usernames',
- 'optionalReviewers',
- 'reviewerMaxRounds',
- 'reviewerModels',
- 'reviewerEfforts',
- 'reviewStopMode',
- 'reviewerApplies',
-];
+// Defaults without changing the task's PR policy or other agent options. The
+// roster is the shared REVIEWER_OVERRIDE_KEYS, so the reset button clears
+// exactly what the server counts as an override when it reports which layer a
+// claim run's reviewers came from.
+const REVIEW_CONFIG_KEYS = REVIEWER_OVERRIDE_KEYS;
export default function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, category: _category, providers, providersLoaded = true, activeProviderId, apps, updating, setUpdating, allTaskTypes, improvementDisabled, dataInputCatalog }) {
const reviewDefaults = useCodeReviewDefaults();
diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md
index 74710729ab..60db94d25f 100644
--- a/client/src/hooks/README.md
+++ b/client/src/hooks/README.md
@@ -165,6 +165,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| `useTaskModelPins` | Provider/model/effort pins for one CoS scheduled task: optimistic write per change (`'' → null` clears; picking a provider clears model+effort, picking a model clears the effort only when that model has no tiers at all), rollback when `onUpdate` resolves falsy, a `saving` flag to gate "Run Now", plus the derived `availableModels` (Antigravity base models, stale pin kept selectable), `effectiveProviderId` and `defaultProviderLabel` a `ProviderModelSelector` needs. | Any surface that retargets a scheduled task's model (schedule card quick controls, config drawer Global defaults) — don't re-roll the select/PUT/rollback trio. |
| `useCanonPatch` | Optimistic canon-entry patch: rebuild the kind list with one entry mutated, apply locally, PATCH the universe, re-apply the server copy. Targets + staleness-guards on the loaded record's `universe.id` so a mid-flight universe swap can't cross-PATCH or resurrect stale state. `apply` is `setUniverse` or `onUniverseChange`. | Inline canon-field edits on a universe (UniverseCanonSection, NounsStage). Don't re-roll the optimistic-then-confirm dance. |
| `useColorMatch` | Drives a song color-match run: counts the singer in with the metronome, walks the notated score in tempo, grades each note against the live mic pitch (#1022 tracker + colorMatch lib), and exposes `{ running, countingIn, noteColors, summary, activeIndex, start, stop }` for the `` + an accuracy readout. Taps the passed recording stream (no second mic); tears down on stop/unmount. | The Song editor's color-match panel. Don't re-wire the metronome + tracker + grading loop by hand. |
+| `useClaimReviewers` | The reviewers a `/do:next` claim will ACTUALLY run for one app — claim-work task metadata resolved OVER the Code Review Defaults, with `source` naming the layer that won. Stays `resolved: false` on a failed lookup so "couldn't ask" never reads as "nothing configured". | Any claim launcher (SlashDoRunDrawer, IssuesTab). `useCodeReviewDefaults` can't see a claim-work override and would show reviewers the run won't use. |
| `useCodeReviewDefaults` | Global Code Review Defaults (Review Loop reviewer chain + every per-reviewer `Model` / `Effort` pin scalar) via a small Provider/hook pair. | TaskAddForm, ScheduleTab, anywhere a default reviewer picker is shown. |
| `useCatalogTypes` | Catalog ingredient type registry (system + user-defined) merged with the static fallback via a Provider/hook pair; synchronous fallback to the built-in six so first render never blanks. | Catalog list/picker/editor; anywhere the catalog type list/lookup is needed. |
| `useDeathClock` | 1-second countdown for death-clock display. | Mortality / death-clock surfaces. |
diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js
index 3f95bbd845..c7ab19603f 100644
--- a/client/src/hooks/index.js
+++ b/client/src/hooks/index.js
@@ -138,6 +138,7 @@ export * from './useMediaCompletionRefresh.js';
export * from './useOpenClawAttachments.js';
// === Settings-derived shared state ===
+export { default as useClaimReviewers } from './useClaimReviewers.js';
export * from './useCodeReviewDefaults.jsx';
export { default as useCatalogTypes } from './useCatalogTypes.jsx';
export * from './useCatalogTypes.jsx';
diff --git a/client/src/hooks/useClaimReviewers.js b/client/src/hooks/useClaimReviewers.js
new file mode 100644
index 0000000000..0a079fd404
--- /dev/null
+++ b/client/src/hooks/useClaimReviewers.js
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+import * as api from '../services/api';
+
+// Sentinel shape while the lookup is in flight or has failed. `reviewers: null`
+// is deliberately NOT `[]` — an empty array is a real answer ("this app resolves
+// to no reviewers") and a caller that seeds a picker from it would render an
+// empty chain as though it were configured. Callers gate on `resolved`.
+const PENDING = Object.freeze({
+ resolved: false,
+ source: null,
+ reviewers: null,
+ usernames: [],
+ optionalReviewers: [],
+ reviewerMaxRounds: {},
+ reviewerModels: {},
+ reviewerEfforts: {},
+ csv: ''
+});
+
+/**
+ * The reviewers a `/do:next` claim will ACTUALLY run for `appId`.
+ *
+ * Distinct from `useCodeReviewDefaults`, and the distinction is the whole point:
+ * the defaults hook reads Models → Code Reviewers, while a claim resolves its
+ * reviewers as claim-work task metadata FIRST and only falls back to those
+ * defaults. A `claim-work` reviewer override therefore runs a chain the defaults
+ * hook cannot see — which is how a claim launched from the Issues tab came to
+ * review with `codex` while every reviewer control on screen showed
+ * `antigravity`. Seed claim surfaces from here; `source` says which layer won.
+ *
+ * A failed lookup stays `resolved: false` rather than reporting an empty chain —
+ * "couldn't ask" and "nothing configured" must not collapse. Fetches once per
+ * mount; a claim drawer is mounted only while open, which is the refresh.
+ */
+export default function useClaimReviewers(appId) {
+ const [value, setValue] = useState(PENDING);
+
+ useEffect(() => {
+ if (!appId) {
+ setValue(PENDING);
+ return undefined;
+ }
+ let cancelled = false;
+ setValue(PENDING);
+ api.getAppClaimReviewers(appId)
+ .then((data) => {
+ if (cancelled || !Array.isArray(data?.reviewers)) return;
+ setValue({
+ resolved: true,
+ source: data.source || null,
+ reviewers: data.reviewers,
+ usernames: Array.isArray(data.usernames) ? data.usernames : [],
+ optionalReviewers: Array.isArray(data.optionalReviewers) ? data.optionalReviewers : [],
+ reviewerMaxRounds: data.reviewerMaxRounds || {},
+ reviewerModels: data.reviewerModels || {},
+ reviewerEfforts: data.reviewerEfforts || {},
+ csv: data.csv || ''
+ });
+ })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [appId]);
+
+ return value;
+}
diff --git a/client/src/lib/reviewerPins.js b/client/src/lib/reviewerPins.js
index 751c91b456..c103d294e4 100644
--- a/client/src/lib/reviewerPins.js
+++ b/client/src/lib/reviewerPins.js
@@ -191,6 +191,25 @@ export const REVIEW_STOP_MODES = [
];
export const DEFAULT_REVIEW_STOP_MODE = 'all';
+// The task-metadata keys that together form a task-local reviewer override —
+// mirror of REVIEWER_OVERRIDE_KEYS. A task type carrying any of them has pinned
+// its own reviewers and no longer tracks the install-wide Code Review Defaults,
+// which is what the picker's "Use system Code Review Defaults" reset clears. A
+// key missing here leaves that reset visible after it has already removed
+// everything it knows about, and leaves the removed key silently pinning a
+// reviewer the user thinks they just cleared.
+export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
+ 'reviewer',
+ 'reviewers',
+ 'usernames',
+ 'optionalReviewers',
+ 'reviewerMaxRounds',
+ 'reviewerModels',
+ 'reviewerEfforts',
+ 'reviewStopMode',
+ 'reviewerApplies',
+]);
+
// Resolve task metadata to an ordered, deduped reviewer list (mirror of the
// server's normalizeReviewers): prefers `reviewers`, falls back to the legacy
// single `reviewer`, defaults to DEFAULT_REVIEWERS.
diff --git a/client/src/services/apiApps.js b/client/src/services/apiApps.js
index 3d24513172..57a5b12859 100644
--- a/client/src/services/apiApps.js
+++ b/client/src/services/apiApps.js
@@ -33,6 +33,17 @@ export const getAppWorkItems = (id, { issueAuthorFilter } = {}, options) => {
const qs = issueAuthorFilter ? `?issueAuthorFilter=${encodeURIComponent(issueAuthorFilter)}` : '';
return request(`/apps/${id}/work-items${qs}`, { silent: true, ...options });
};
+// The reviewers a `/do:next` claim will ACTUALLY run for this app, resolved
+// server-side through the claim-work task metadata AND the install-wide Code
+// Review Defaults: `{ source, reviewers, usernames, optionalReviewers,
+// reviewerMaxRounds, reviewerModels, reviewerEfforts, csv }`. `source` is
+// 'task-override' when a claim-work override supplied the list (the defaults
+// were never consulted) or 'defaults' when it came from Settings → Code
+// Reviewers. Seed a claim surface from THIS, not from getCodeReviewDefaults —
+// the latter can't see the override and so shows reviewers the run won't use.
+// Read-only; callers own their fallback, so default to silent.
+export const getAppClaimReviewers = (id, options) =>
+ request(`/apps/${id}/claim-reviewers`, { silent: true, ...options });
// Every OPEN issue on the forge this app's git origin points at (GitHub via gh,
// GitLab via glab): { forge, fullName, issues: [{ number, title, body, labels,
// assignees, author, url, createdAt, updatedAt }], reason, transient }. Backs the
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index f368f055ab..d06e1fbbfe 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -966,6 +966,14 @@
"server/routes/apps/lifecycle.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/apps/:id/claim-reviewers",
+ "mountPath": "/api/apps",
+ "sources": [
+ "server/routes/apps/taskTypes.js"
+ ]
+ },
{
"method": "POST",
"path": "/api/apps/:id/detect-icon",
diff --git a/server/lib/reviewerConfig.js b/server/lib/reviewerConfig.js
index 6b13e18781..ed5042bbf7 100644
--- a/server/lib/reviewerConfig.js
+++ b/server/lib/reviewerConfig.js
@@ -178,6 +178,37 @@ export function describeReviewerCli(reviewer) {
export const REVIEW_STOP_MODES = ['all', 'on-findings', 'on-clean'];
export const DEFAULT_REVIEW_STOP_MODE = 'all';
+// The task-metadata keys that TOGETHER form a task-local reviewer override. A
+// task type carrying ANY of them has pinned its own reviewers and no longer
+// tracks the install-wide Code Review Defaults — which is the point of the
+// override, and also the reason a claim launched from the UI can name a reviewer
+// the Code Reviewers panel no longer lists. One roster so the resolver, the `source`
+// claim-reviewer lookup reports, and the picker's "Use system Code Review
+// Defaults" reset all agree on what counts as an override.
+// Mirrored in client/src/lib/reviewerPins.js.
+export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
+ // Legacy singular, still stored on schedules saved before the list existed.
+ 'reviewer',
+ 'reviewers',
+ 'usernames',
+ 'optionalReviewers',
+ 'reviewerMaxRounds',
+ 'reviewerModels',
+ 'reviewerEfforts',
+ 'reviewStopMode',
+ 'reviewerApplies',
+]);
+
+/**
+ * Does this task metadata pin its own reviewers? Key PRESENCE is the signal, not
+ * truthiness: an explicitly empty `optionalReviewers: []` or `reviewerModels: {}`
+ * is a real override (it clears the defaults' value), so collapsing it into
+ * "nothing configured" would report the wrong source.
+ */
+export function hasReviewerOverride(metadata) {
+ return isPlainObject(metadata) && REVIEWER_OVERRIDE_KEYS.some((key) => key in metadata);
+}
+
// Arbitrary GitHub reviewer usernames (e.g. `@CodeReviewbot`) requested as PR
// reviewers to gate merging — a class distinct from the fixed REVIEWER_VALUES
// enum (which either invoke a CLI, hit the local-LLM endpoint, or request the
diff --git a/server/lib/reviewerConfig.test.js b/server/lib/reviewerConfig.test.js
index e14d087148..342da4f689 100644
--- a/server/lib/reviewerConfig.test.js
+++ b/server/lib/reviewerConfig.test.js
@@ -34,6 +34,8 @@ import {
PORTOS_ONLY_REVIEWERS,
DEFAULT_REVIEWERS,
REVIEW_STOP_MODES,
+ REVIEWER_OVERRIDE_KEYS,
+ hasReviewerOverride,
DEFAULT_REVIEW_STOP_MODE,
MAX_REVIEW_USERNAMES,
MAX_REVIEWER_MAX_ROUNDS,
@@ -437,6 +439,16 @@ describe('client mirror of the reviewer vocabulary', () => {
expect(client.MAX_REVIEW_USERNAMES).toBe(MAX_REVIEW_USERNAMES);
});
+ // The override roster decides two things that must agree: the picker's "Use
+ // system Code Review Defaults" reset (client) removes exactly these keys, and
+ // the claim-reviewer lookup (server) calls a task an override when any is
+ // present. A key on one side only means the reset leaves a pin behind while
+ // the lookup keeps reporting `task-override` for a task the user just cleared.
+ it('matches the server reviewer-override key roster', async () => {
+ const client = await import('../../client/src/lib/reviewerPins.js');
+ expect(client.REVIEWER_OVERRIDE_KEYS).toEqual([...REVIEWER_OVERRIDE_KEYS]);
+ });
+
// Stop modes: the client rows carry UI copy, but their `value`s are the enum
// the save is validated against. An extra row 400s the whole task update.
it('matches the server review stop modes', async () => {
@@ -789,3 +801,37 @@ describe('codeReviewDefaultsFromProvider', () => {
expect(codeReviewDefaultsFromProvider(null)).toBeNull();
});
});
+
+// `hasReviewerOverride` is what a claim-reviewer lookup reports as its `source`,
+// and the user acts on that answer ("clear the override" vs "change the
+// defaults"). The interesting cases are the ones truthiness gets wrong.
+describe('hasReviewerOverride', () => {
+ it('reports an override for any single reviewer key, including the legacy singular', () => {
+ for (const key of REVIEWER_OVERRIDE_KEYS) {
+ expect(hasReviewerOverride({ [key]: undefined })).toBe(true);
+ }
+ expect(hasReviewerOverride({ reviewer: 'codex' })).toBe(true);
+ });
+
+ it('treats an explicitly EMPTY pin as an override — it clears the defaults value', () => {
+ // `[]`/`{}` are the shape a user gets by unmarking every optional reviewer or
+ // clearing every model pin. Gating on truthiness would call that "unset" and
+ // send the user to the Code Reviewers panel, which is not where the value
+ // they are seeing comes from.
+ expect(hasReviewerOverride({ optionalReviewers: [] })).toBe(true);
+ expect(hasReviewerOverride({ reviewerModels: {} })).toBe(true);
+ expect(hasReviewerOverride({ reviewerApplies: false })).toBe(true);
+ expect(hasReviewerOverride({ reviewerMaxRounds: {} })).toBe(true);
+ });
+
+ it('reports no override for task metadata that only carries non-reviewer options', () => {
+ expect(hasReviewerOverride({ useWorktree: false, openPR: false, claimFlow: true, simplify: true })).toBe(false);
+ expect(hasReviewerOverride({ reviewLoop: true, issueAuthorFilter: 'owner' })).toBe(false);
+ });
+
+ it('reports no override for a non-object', () => {
+ for (const value of [null, undefined, [], 'reviewers', 7]) {
+ expect(hasReviewerOverride(value)).toBe(false);
+ }
+ });
+});
diff --git a/server/routes/apps/taskTypes.js b/server/routes/apps/taskTypes.js
index 5e9b5aa8f4..5f37ae84f3 100644
--- a/server/routes/apps/taskTypes.js
+++ b/server/routes/apps/taskTypes.js
@@ -6,6 +6,7 @@
* GET /:id/task-types → { taskTypeOverrides }
* GET /:id/work-tracker → { tracker info }
* GET /:id/work-items → { tracker, items, reason }
+ * GET /:id/claim-reviewers → { source, reviewers, csv, … }
* GET /:id/layered-intelligence → { config, isPortos }
* GET /:id/layered-intelligence/outcomes → { stats, execution, metrics, approvalFunnel, rejections, recent }
* PUT /:id/task-types/all → { success, taskTypeOverrides }
@@ -19,9 +20,10 @@ import { Router } from 'express';
import { logCosScheduleUpdate } from '../../services/userActionScheduleLog.js';
import * as appsService from '../../services/apps.js';
import { PORTOS_APP_ID } from '../../services/apps.js';
-import { sanitizeTaskMetadata, ISSUE_AUTHOR_FILTERS } from '../../lib/validation.js';
+import { sanitizeTaskMetadata, ISSUE_AUTHOR_FILTERS, resolveClaimReviewerConfig, hasReviewerOverride } from '../../lib/validation.js';
import { listWorkItems } from '../../services/workItems.js';
import { resolveClaimWorkMetadata, resolveClaimAuthorFilter } from '../../services/cosTaskGenerator.js';
+import { getCodeReviewDefaults } from '../../services/codeReview.js';
import { parseCronToNextRun } from '../../services/eventScheduler.js';
import { INTERVAL_TYPES, decodeIntervalType, isCronExpression, isKnownIntervalType } from '../../services/taskScheduleConstants.js';
import { asyncHandler, ServerError } from '../../lib/errorHandler.js';
@@ -96,6 +98,46 @@ router.get('/:id/work-items', loadApp, asyncHandler(async (req, res) => {
res.json({ appId: app.id, appName: app.name, issueAuthorFilter, ...result });
}));
+// GET /api/apps/:id/claim-reviewers - The reviewers a `/do:next` claim will
+// ACTUALLY run for this app, resolved through the same
+// `resolveClaimWorkMetadata` → `resolveClaimReviewerConfig` chain
+// `buildClaimWorkTask` uses to fill the claim prompt's `{reviewers}` token.
+//
+// The manual claim surfaces used to seed their reviewer display straight from
+// `GET /api/code-review/defaults`, which skips the claim-work task metadata
+// layer entirely. A `claim-work` override left over from an earlier reviewer
+// choice therefore ran reviewers the Code Reviewers panel no longer listed, with the
+// drawer confidently showing the panel's list — so the run named `codex` while
+// the UI said `antigravity`. `source` is what makes that visible: `task-override`
+// means an override supplied the list and the install defaults were not
+// consulted, `defaults` means they were.
+//
+// Read-only: metadata + settings reads, no claim markers, no LLM call.
+router.get('/:id/claim-reviewers', loadApp, asyncHandler(async (req, res) => {
+ const app = req.loadedApp;
+ // Independent reads — the resolver needs both, but neither depends on the other.
+ const [{ metadata }, codeReviewDefaults] = await Promise.all([
+ resolveClaimWorkMetadata(app),
+ // A settings read failure means "no configured defaults", never a failed
+ // lookup: the resolver still answers from the task metadata, which is the
+ // layer that wins anyway.
+ getCodeReviewDefaults().catch(() => null)
+ ]);
+ const config = resolveClaimReviewerConfig(metadata, codeReviewDefaults, codeReviewDefaults?.reviewers);
+ res.json({
+ appId: app.id,
+ appName: app.name,
+ source: hasReviewerOverride(metadata) ? 'task-override' : 'defaults',
+ reviewers: config.reviewers,
+ usernames: config.usernames,
+ optionalReviewers: config.optionalReviewers,
+ reviewerMaxRounds: config.reviewerMaxRounds,
+ reviewerModels: config.reviewerModels,
+ reviewerEfforts: config.reviewerEfforts,
+ csv: config.csv
+ });
+}));
+
// GET /api/apps/:id/layered-intelligence - Effective Layered Intelligence config
// for this app (the self-improvement loop). Merges the app's stored partial
// config over the shipped defaults so the UI always renders a complete, safe
diff --git a/server/routes/apps/taskTypes.test.js b/server/routes/apps/taskTypes.test.js
index 9900a276be..feb70ec192 100644
--- a/server/routes/apps/taskTypes.test.js
+++ b/server/routes/apps/taskTypes.test.js
@@ -36,11 +36,18 @@ vi.mock('../../services/cosTaskGenerator.js', async (importActual) => ({
...(await importActual()),
resolveClaimWorkMetadata: vi.fn()
}));
+// Only the settings READ is mocked; resolveClaimReviewerConfig (the layer
+// precedence, the copilot guard, and the emitted CSV) runs for real, so these
+// assert the reviewers a claim would genuinely get rather than a restated stub.
+vi.mock('../../services/codeReview.js', () => ({
+ getCodeReviewDefaults: vi.fn()
+}));
import * as appsService from '../../services/apps.js';
import { listOutcomesResult } from '../../services/layeredIntelligenceOutcomes.js';
import { listWorkItems } from '../../services/workItems.js';
import { resolveClaimWorkMetadata } from '../../services/cosTaskGenerator.js';
+import { getCodeReviewDefaults } from '../../services/codeReview.js';
describe('Apps Task-Type Routes', () => {
let app;
@@ -115,6 +122,73 @@ describe('Apps Task-Type Routes', () => {
});
});
+ // The reviewer chain a manual claim runs is resolved from TWO layers, and the
+ // whole point of this route is that the second one can be invisible: a
+ // claim-work task override wins over the install-wide Code Review Defaults, so
+ // a UI seeded from the defaults alone shows reviewers the run will not use.
+ describe('GET /api/apps/:id/claim-reviewers', () => {
+ beforeEach(() => {
+ appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'App' });
+ getCodeReviewDefaults.mockResolvedValue({ reviewers: ['antigravity'], antigravityModel: 'gemini-3.8-flash' });
+ });
+
+ it('answers from the Code Review Defaults, marked `defaults`, when the task pins nothing', async () => {
+ resolveClaimWorkMetadata.mockResolvedValue({ metadata: { useWorktree: false, claimFlow: true }, interval: {} });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.status).toBe(200);
+ expect(response.body.source).toBe('defaults');
+ expect(response.body.reviewers).toEqual(['antigravity']);
+ expect(response.body.csv).toBe('antigravity[gemini-3.8-flash]');
+ });
+
+ it('reports the claim-work override that WINS over the defaults, marked `task-override`', async () => {
+ // The #6202 shape: the install default had been moved to `antigravity`, but a
+ // claim-work override saved months earlier still named codex + claude, so
+ // every manual claim reviewed with those while every reviewer control on
+ // screen showed `antigravity`.
+ resolveClaimWorkMetadata.mockResolvedValue({
+ metadata: { claimFlow: true, reviewers: ['codex', 'claude'] },
+ interval: {}
+ });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.status).toBe(200);
+ expect(response.body.source).toBe('task-override');
+ expect(response.body.reviewers).toEqual(['codex', 'claude']);
+ expect(response.body.csv).toBe('codex,claude');
+ });
+
+ it('drops copilot from a claim chain, matching what the claim prompt is given', async () => {
+ // claimSafeReviewers: copilot has no CLI, so a claim agent told to review
+ // with it stalls. The lookup has to show the SAME post-guard list the
+ // prompt gets, or the UI advertises a reviewer the run silently removes.
+ resolveClaimWorkMetadata.mockResolvedValue({
+ metadata: { reviewers: ['copilot', 'claude'] },
+ interval: {}
+ });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.body.reviewers).toEqual(['claude']);
+ });
+
+ it('still answers from the task override when the settings read fails', async () => {
+ // A failed settings read means "no configured defaults", never a failed
+ // lookup — the layer that wins is the task metadata either way.
+ getCodeReviewDefaults.mockRejectedValue(new Error('settings unavailable'));
+ resolveClaimWorkMetadata.mockResolvedValue({ metadata: { reviewers: ['grok'] }, interval: {} });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.status).toBe(200);
+ expect(response.body.source).toBe('task-override');
+ expect(response.body.reviewers).toEqual(['grok']);
+ });
+ });
+
describe('GET /api/apps/:id/layered-intelligence', () => {
it('returns the effective config + isPortos flag', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'App' });
From 8147c1d32076c6210da1d13360d8ac691483334b Mon Sep 17 00:00:00 2001
From: "[._.]/ Adam Eivy"
Date: Fri, 4 Sep 2026 05:38:44 +0000
Subject: [PATCH 2/3] share one claim reviewer resolver, and make the override
the UI names clearable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cleanup on the claim-reviewer lookup, plus the one gap that made its advice
unfollowable.
- `claimReviewersFrom` / `resolveAppClaimReviewers` (cosTaskGenerator) now own the
layer precedence for both the claim builder and the lookup route. The route had
hand-copied that chain, which is the drift the lookup exists to prevent.
- `hasReviewerOverride` keys on `REVIEWER_LIST_OVERRIDE_KEYS`, not the full
roster: `reviewStopMode` / `reviewerApplies` are slashdo run flags and a claim
prompt has no flag string to put them in, so neither can change which reviewers
run. Reporting a stop-mode as the source sent the user to clear a pin that
wasn't supplying the list they were looking at. The wide roster stays for the
picker's reset, which does clear both.
- `ClaimReviewerSource` renders the "where this came from" sentence for both
surfaces; they had already diverged on which panel they pointed at.
- The reviewer picker — and the "Use system Code Review Defaults" reset beside it
— now render for claimFlow task types. Their shipped metadata sets neither
`openPR` nor `reviewLoop`, so the picker never appeared for `claim-work`: the
override every claim obeys had no control anywhere that could clear it, while
the claim surfaces told the user to come here and do exactly that.
- `useClaimReviewers` returns the payload or `null` instead of a 9-field sentinel
with a derivable `resolved` flag, which flattens the drawer's seeding memo.
- Trimmed the retold rationale to the resolution site, and the route's response
test down to what the route itself decides — the resolution it previews is
covered behaviorally on the shared resolver.
Follow-ups filed: #6208 (the picker persists a defaults snapshot as an override,
which is what manufactured the stale pin) and #6210 (the JIRA play button skips
the claim-work layer its own docstring promises to honor).
---
.../components/apps/ClaimReviewerSource.jsx | 33 ++++++++
.../src/components/apps/SlashDoRunDrawer.jsx | 53 +++++-------
.../components/apps/SlashDoRunDrawer.test.jsx | 25 +++---
client/src/components/apps/tabs/IssuesTab.jsx | 27 +++----
client/src/components/cos/constants.js | 2 +
.../tabs/schedule/GlobalConfigControls.jsx | 32 +++++---
.../schedule/GlobalConfigControls.test.jsx | 11 +++
client/src/hooks/useClaimReviewers.js | 66 +++++----------
client/src/lib/reviewerPins.js | 26 ++++--
server/lib/README.md | 2 +-
server/lib/apiRouteCatalog.generated.json | 4 +-
server/lib/reviewerConfig.js | 33 +++++---
server/lib/reviewerConfig.test.js | 32 ++++++--
server/routes/apps/taskTypes.js | 55 ++++++-------
server/routes/apps/taskTypes.test.js | 80 +++++++------------
server/services/cosTaskGenerator.js | 68 ++++++++++++----
server/services/cosTaskGenerator.test.js | 72 +++++++++++++++--
17 files changed, 375 insertions(+), 246 deletions(-)
create mode 100644 client/src/components/apps/ClaimReviewerSource.jsx
diff --git a/client/src/components/apps/ClaimReviewerSource.jsx b/client/src/components/apps/ClaimReviewerSource.jsx
new file mode 100644
index 0000000000..ee55075798
--- /dev/null
+++ b/client/src/components/apps/ClaimReviewerSource.jsx
@@ -0,0 +1,33 @@
+import { Link } from 'react-router';
+
+/**
+ * Where a claim's reviewer list came from, in one sentence.
+ *
+ * Shared by the two manual claim surfaces because they were already drifting on
+ * the answer: a claim resolves the claim-work task metadata FIRST and only falls
+ * back to the install-wide Code Review Defaults, so a user who changed the
+ * defaults and sees a different chain needs to be sent to whichever one is
+ * actually supplying it — and being sent to the wrong panel is the same class of
+ * confusion as not being told at all.
+ *
+ * The override lives on the `claim-work` task, editable both globally (Chief of
+ * Staff → Schedule) and per app (the app's Automation tab), so the copy names
+ * the task rather than a single screen.
+ */
+export default function ClaimReviewerSource({ source }) {
+ if (source === 'task-override') {
+ return (
+ <>
+ {' — from the '}claim-work {' reviewer override ('}
+ Chief of Staff → Schedule
+ {', or this app’s Automation tab), not Models → Code Reviewers. Clear it there to follow the install default again.'}
+ >
+ );
+ }
+ return (
+ <>
+ {' — from '}
+ Models → Code Reviewers.
+ >
+ );
+}
diff --git a/client/src/components/apps/SlashDoRunDrawer.jsx b/client/src/components/apps/SlashDoRunDrawer.jsx
index 297445924a..a373533acd 100644
--- a/client/src/components/apps/SlashDoRunDrawer.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.jsx
@@ -8,6 +8,7 @@ import useReviewerModelOptions from '../../hooks/useReviewerModelOptions';
import { reviewerModelsFromDefaults, reviewerEffortsFromDefaults } from '../../lib/reviewerModels';
import { CodeReviewDefaultsProvider, useCodeReviewDefaults } from '../../hooks/useCodeReviewDefaults';
import useClaimReviewers from '../../hooks/useClaimReviewers';
+import ClaimReviewerSource from './ClaimReviewerSource';
import { isProcessProvider } from '../../utils/providers';
import WorkItemPicker from './WorkItemPicker';
import * as api from '../../services/api';
@@ -33,7 +34,8 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
const codeReviewDefaults = useCodeReviewDefaults();
// What a claim actually resolves for this app — the claim-work override layer
// the defaults above cannot see. Only `/do:next` reads reviewers server-side,
- // so no other command pays for the lookup.
+ // so no other command pays for the lookup. `installed` still comes from the
+ // defaults, which is why both are fetched.
const claimReviewers = useClaimReviewers(command === 'next' ? appId : null);
// Resolved model lists for the reviewer table's Model column (the picker never
// fetches — see its `modelOptions` prop).
@@ -50,33 +52,18 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
// Seeded from what the RUN will resolve for display. `review` staying null is
// what gates whether the fields are SENT — see the component doc.
const [review, setReview] = useState(null);
- // Seeded display for an untouched picker. It has to show the reviewers the run
- // would resolve, which is NOT the Code Review Defaults: a claim resolves its
- // claim-work task metadata first and only falls back to them, so an override
- // there runs a chain this drawer previously never showed (a claim reviewed with
- // `codex` while the picker displayed `antigravity`). `claimReviewers` is that
- // resolution; the defaults remain the fallback for the window before it lands
- // and for a lookup that failed — an unresolved lookup must not seed an empty
- // chain, which would read as "no reviewers configured".
- //
- // The defaults carry per-reviewer models and efforts as `Model` /
- // `Effort` scalars; the picker takes the token-keyed maps, so fold them
- // in for that fallback.
+ // Seeded display for an untouched picker. It has to show the reviewers the RUN
+ // would resolve, which is not the Code Review Defaults whenever a claim-work
+ // override is in play (see `GET /apps/:id/claim-reviewers`). The defaults are
+ // the fallback for the window before the lookup lands and for one that failed —
+ // they carry per-reviewer pins as `Model` / `Effort`
+ // scalars, which the picker takes as token-keyed maps.
const seededReview = useMemo(
- () => (claimReviewers.resolved
- ? {
- reviewers: claimReviewers.reviewers,
- usernames: claimReviewers.usernames,
- optionalReviewers: claimReviewers.optionalReviewers,
- reviewerMaxRounds: claimReviewers.reviewerMaxRounds,
- reviewerModels: claimReviewers.reviewerModels,
- reviewerEfforts: claimReviewers.reviewerEfforts,
- }
- : {
- ...codeReviewDefaults,
- reviewerModels: reviewerModelsFromDefaults(codeReviewDefaults),
- reviewerEfforts: reviewerEffortsFromDefaults(codeReviewDefaults),
- }),
+ () => claimReviewers ?? {
+ ...codeReviewDefaults,
+ reviewerModels: reviewerModelsFromDefaults(codeReviewDefaults),
+ reviewerEfforts: reviewerEffortsFromDefaults(codeReviewDefaults),
+ },
[claimReviewers, codeReviewDefaults]
);
const reviewValue = review ?? seededReview;
@@ -196,13 +183,11 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
{/* Which layer supplied the seeded list. A claim-work override wins
over Models → Code Reviewers silently, so a user who changed the
- install default and sees a different chain here needs to be told
- where it came from — that mismatch is exactly what sent a claim to
- `codex` after the defaults had been moved to `antigravity`. */}
- {!review && claimReviewers.source === 'task-override' && (
-
- These come from the claim-work task override in Chief of Staff → Schedule, not from
- Models → Code Reviewers. Clear it there (“Use system Code Review Defaults”) to follow the install default again.
+ install default and sees a different chain here has to be told
+ where it came from. */}
+ {!review && claimReviewers?.source === 'task-override' && (
+
+ These come
)}
>
diff --git a/client/src/components/apps/SlashDoRunDrawer.test.jsx b/client/src/components/apps/SlashDoRunDrawer.test.jsx
index 7d451c8aa0..24d4c38df5 100644
--- a/client/src/components/apps/SlashDoRunDrawer.test.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.test.jsx
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router';
import SlashDoRunDrawer from './SlashDoRunDrawer';
const api = vi.hoisted(() => ({
@@ -25,17 +26,21 @@ vi.mock('../../services/apiLocalLlm', () => ({
getToolUseModels: vi.fn(() => new Promise(() => {})),
}));
+// Routed: the override note links to the panel that owns the pin, so the drawer
+// needs a router the way it has one in the app.
const renderDrawer = (props = {}) => render(
-
+
+
+
);
describe('SlashDoRunDrawer', () => {
diff --git a/client/src/components/apps/tabs/IssuesTab.jsx b/client/src/components/apps/tabs/IssuesTab.jsx
index 5ed74cd409..07510c8129 100644
--- a/client/src/components/apps/tabs/IssuesTab.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.jsx
@@ -13,6 +13,7 @@ import { useThemeContext } from '../../ThemeContext';
import { useCosTaskUpdates } from '../../../hooks/useCosTaskUpdates';
import useProviderModels from '../../../hooks/useProviderModels';
import useClaimReviewers from '../../../hooks/useClaimReviewers';
+import ClaimReviewerSource from '../ClaimReviewerSource';
import { chipColors } from '../../../lib/chipContrast';
import { isProcessProvider } from '../../../utils/providers';
import * as api from '../../../services/api';
@@ -255,12 +256,10 @@ export default function IssuesTab({ appId, appName }) {
} = useProviderModels({ filter: enabledProcessProviderFilter, allowDefault: true, silent: true, withEffort: true });
const [effort, setEffort] = useState('');
const [overrideContext, setOverrideContext] = useState('');
- // The reviewers a Claim launched from this tab will actually run. This tab
- // offers no reviewer picker, so the resolution was previously invisible here —
- // and it is NOT the Models → Code Reviewers list a user would assume: a
- // claim-work task override wins over it, which is how a claim from this tab
- // reviewed with `codex` after the install default had been changed. Surfaced
- // read-only next to the provider pin, with its source named.
+ // The reviewers a Claim launched from this tab will actually run — NOT the
+ // Models → Code Reviewers list, whenever a claim-work override is in play (see
+ // `GET /apps/:id/claim-reviewers`). This tab has no reviewer picker, so it
+ // names them read-only beside the provider pin.
const claimReviewers = useClaimReviewers(appId);
// Keep the event-driven path based on the latest runs without putting a
@@ -585,20 +584,18 @@ export default function IssuesTab({ appId, appName }) {
/>
- {claimReviewers.resolved && (
+ {claimReviewers && (
Reviewed by
- {claimReviewers.reviewers.length
- ? {claimReviewers.csv || claimReviewers.reviewers.join(',')}
- : 'No reviewers resolve for this app.'}
- {claimReviewers.source === 'task-override'
- ? <> — from the claim-work task override in{' '}
- Chief of Staff → Schedule,
- not Models → Code Reviewers. Clear it there to follow the install default.>
- : <> — from Models → Code Reviewers.>}
+ {claimReviewers.reviewers.length ? (
+ <>
+ {claimReviewers.csv}
+
+ >
+ ) : 'No reviewers resolve for this app — a Claim will merge without one.'}
)}
diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js
index ba06b8bb02..926f778578 100644
--- a/client/src/components/cos/constants.js
+++ b/client/src/components/cos/constants.js
@@ -336,6 +336,8 @@ export {
REVIEW_STOP_MODES,
DEFAULT_REVIEW_STOP_MODE,
REVIEWER_OVERRIDE_KEYS,
+ REVIEWER_LIST_OVERRIDE_KEYS,
+ hasReviewerOverride,
normalizeReviewers,
MODEL_CAPABLE_CLI_REVIEWERS,
LOCAL_LLM_REVIEWERS,
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
index 4f125f53c0..a81269b5d7 100644
--- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
+++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
import useFieldDraft from '../../../../hooks/useFieldDraft';
import { RotateCcw, AlertCircle } from 'lucide-react';
import CronInput from '../../../CronInput';
-import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, REVIEWER_OVERRIDE_KEYS, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
+import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, REVIEWER_OVERRIDE_KEYS as REVIEW_CONFIG_KEYS, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
import ReviewerPicker from '../../ReviewerPicker';
import Banner from '../../../ui/Banner';
import InfoTooltip from '../../../ui/InfoTooltip';
@@ -25,13 +25,14 @@ import { INTERVAL_DESCRIPTIONS, PERPETUAL_DESCRIPTION, toggleMetadataField, pipe
// runs (no app) land on the server-side fallback.
const PR_COMPLETION_INHERIT_HINT = `Uses the target app's "After opening PR" default (Apps → Edit App), or "${prCompletionOption(IMPLICIT_PR_COMPLETION)?.label}" when it has none.`;
-// These fields are the task-local reviewer-loop override. Removing them lets
-// the picker and server resolver fall back to the install-wide Code Review
-// Defaults without changing the task's PR policy or other agent options. The
-// roster is the shared REVIEWER_OVERRIDE_KEYS, so the reset button clears
-// exactly what the server counts as an override when it reports which layer a
-// claim run's reviewers came from.
-const REVIEW_CONFIG_KEYS = REVIEWER_OVERRIDE_KEYS;
+// The task-local reviewer-loop override is REVIEWER_OVERRIDE_KEYS (imported as
+// REVIEW_CONFIG_KEYS above). Removing those keys lets the picker and the server
+// resolver fall back to the install-wide Code Review Defaults without changing
+// the task's PR policy or other agent options.
+//
+// Deliberately the WIDE roster, not `hasReviewerOverride`'s list-bearing subset:
+// the reset clears the two run flags too, so gating its visibility on the subset
+// would leave a stop-mode-only override on screen with no control that removes it.
export default function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, category: _category, providers, providersLoaded = true, activeProviderId, apps, updating, setUpdating, allTaskTypes, improvementDisabled, dataInputCatalog }) {
const reviewDefaults = useCodeReviewDefaults();
@@ -223,9 +224,18 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
// Reviewers only run under review-then-merge, so the picker hides for the two
// policies that never reach them — but an unpinned ('') task may still inherit
// review-then-merge from its app, so that keeps it.
- const reviewersApply = config.taskMetadata?.openPR
- ? prCompletion === '' || prCompletion === 'review-then-merge'
- : !!config.taskMetadata?.reviewLoop;
+ //
+ // A claimFlow task is unconditional: its PROMPT opens and merges its own PR and
+ // runs the reviewers itself, so the resolved list is operative no matter what
+ // `openPR` / `reviewLoop` say (both are false in the shipped claim metadata).
+ // Without this the picker — and the "Use system Code Review Defaults" reset
+ // beside it — never render for claim-work, leaving a reviewer override that
+ // every claim obeys with no control anywhere that can clear it.
+ const reviewersApply = config.taskMetadata?.claimFlow
+ ? true
+ : config.taskMetadata?.openPR
+ ? prCompletion === '' || prCompletion === 'review-then-merge'
+ : !!config.taskMetadata?.reviewLoop;
// `selectedProvider` / `availableModels` come from useTaskModelPins above — it
// resolves the pin against the active provider, lists Antigravity's BASE models
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx
index 0ece7f2585..82d13bf51a 100644
--- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx
+++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx
@@ -111,6 +111,17 @@ describe('GlobalConfigControls — After opening PR', () => {
expect(screen.getByTestId('reviewer-picker')).toBeInTheDocument();
});
+ it('keeps the reviewer picker for a claim flow, whose shipped metadata sets neither flag', () => {
+ // A claim PROMPT opens and merges its own PR and runs the reviewers itself,
+ // so the resolved list is operative even though `openPR` and `reviewLoop` are
+ // both false — which is exactly the shipped `claim-work` metadata. Hiding the
+ // picker here leaves a reviewer override that every claim obeys with no
+ // control anywhere that can clear it, while the claim surfaces tell the user
+ // to come here and do precisely that.
+ renderControls({ taskType: 'claim-work', taskMetadata: { useWorktree: false, openPR: false, claimFlow: true } });
+ expect(screen.getByTestId('reviewer-picker')).toBeInTheDocument();
+ });
+
it('resets the task review override while preserving unrelated task metadata', () => {
const onUpdate = renderControls({
taskMetadata: {
diff --git a/client/src/hooks/useClaimReviewers.js b/client/src/hooks/useClaimReviewers.js
index 0a079fd404..16f58f4a54 100644
--- a/client/src/hooks/useClaimReviewers.js
+++ b/client/src/hooks/useClaimReviewers.js
@@ -1,61 +1,37 @@
import { useEffect, useState } from 'react';
import * as api from '../services/api';
-// Sentinel shape while the lookup is in flight or has failed. `reviewers: null`
-// is deliberately NOT `[]` — an empty array is a real answer ("this app resolves
-// to no reviewers") and a caller that seeds a picker from it would render an
-// empty chain as though it were configured. Callers gate on `resolved`.
-const PENDING = Object.freeze({
- resolved: false,
- source: null,
- reviewers: null,
- usernames: [],
- optionalReviewers: [],
- reviewerMaxRounds: {},
- reviewerModels: {},
- reviewerEfforts: {},
- csv: ''
-});
-
/**
- * The reviewers a `/do:next` claim will ACTUALLY run for `appId`.
+ * The reviewers a `/do:next` claim will ACTUALLY run for `appId`, or `null`
+ * while the lookup is in flight or has failed.
+ *
+ * Distinct from `useCodeReviewDefaults`, and the distinction is the point: the
+ * defaults hook reads Models → Code Reviewers, while a claim resolves its
+ * reviewers from the claim-work task metadata FIRST and only falls back to those
+ * defaults. An override there therefore runs a chain the defaults hook cannot
+ * see — which is how a claim reviewed with `codex` while every reviewer control
+ * on screen showed `antigravity`. Seed claim surfaces from here; the payload's
+ * `source` says which layer won.
*
- * Distinct from `useCodeReviewDefaults`, and the distinction is the whole point:
- * the defaults hook reads Models → Code Reviewers, while a claim resolves its
- * reviewers as claim-work task metadata FIRST and only falls back to those
- * defaults. A `claim-work` reviewer override therefore runs a chain the defaults
- * hook cannot see — which is how a claim launched from the Issues tab came to
- * review with `codex` while every reviewer control on screen showed
- * `antigravity`. Seed claim surfaces from here; `source` says which layer won.
+ * `null` rather than an empty chain, because "couldn't ask" and "nothing
+ * configured" must not collapse: an empty reviewer list rendered as fact would
+ * read as a claim that merges with no review at all.
*
- * A failed lookup stays `resolved: false` rather than reporting an empty chain —
- * "couldn't ask" and "nothing configured" must not collapse. Fetches once per
- * mount; a claim drawer is mounted only while open, which is the refresh.
+ * Fetches once per mount; a claim drawer is mounted only while open, which is
+ * the refresh.
*/
export default function useClaimReviewers(appId) {
- const [value, setValue] = useState(PENDING);
+ const [value, setValue] = useState(null);
useEffect(() => {
- if (!appId) {
- setValue(PENDING);
- return undefined;
- }
+ setValue(null);
+ if (!appId) return undefined;
let cancelled = false;
- setValue(PENDING);
api.getAppClaimReviewers(appId)
.then((data) => {
- if (cancelled || !Array.isArray(data?.reviewers)) return;
- setValue({
- resolved: true,
- source: data.source || null,
- reviewers: data.reviewers,
- usernames: Array.isArray(data.usernames) ? data.usernames : [],
- optionalReviewers: Array.isArray(data.optionalReviewers) ? data.optionalReviewers : [],
- reviewerMaxRounds: data.reviewerMaxRounds || {},
- reviewerModels: data.reviewerModels || {},
- reviewerEfforts: data.reviewerEfforts || {},
- csv: data.csv || ''
- });
+ // The one guard that matters: without a list there is nothing to show,
+ // and every other field is shaped by the route from the same resolver.
+ if (!cancelled && Array.isArray(data?.reviewers)) setValue(data);
})
.catch(() => {});
return () => { cancelled = true; };
diff --git a/client/src/lib/reviewerPins.js b/client/src/lib/reviewerPins.js
index c103d294e4..f7c233fe71 100644
--- a/client/src/lib/reviewerPins.js
+++ b/client/src/lib/reviewerPins.js
@@ -192,12 +192,11 @@ export const REVIEW_STOP_MODES = [
export const DEFAULT_REVIEW_STOP_MODE = 'all';
// The task-metadata keys that together form a task-local reviewer override —
-// mirror of REVIEWER_OVERRIDE_KEYS. A task type carrying any of them has pinned
-// its own reviewers and no longer tracks the install-wide Code Review Defaults,
-// which is what the picker's "Use system Code Review Defaults" reset clears. A
-// key missing here leaves that reset visible after it has already removed
-// everything it knows about, and leaves the removed key silently pinning a
-// reviewer the user thinks they just cleared.
+// mirror of REVIEWER_OVERRIDE_KEYS. Everything the picker writes, and so
+// everything its "Use system Code Review Defaults" reset has to remove. A key
+// missing here leaves that reset visible after it has already removed everything
+// it knows about, and leaves the removed key silently pinning a reviewer the
+// user thinks they just cleared.
export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
'reviewer',
'reviewers',
@@ -210,6 +209,21 @@ export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
'reviewerApplies',
]);
+// The subset that can actually change the resolved reviewer LIST — mirror of
+// REVIEWER_LIST_OVERRIDE_KEYS. The two run flags are excluded: a claim flow has
+// no slashdo flag string to put them in, so neither changes which reviewers run.
+export const REVIEWER_LIST_OVERRIDE_KEYS = Object.freeze(
+ REVIEWER_OVERRIDE_KEYS.filter((key) => key !== 'reviewStopMode' && key !== 'reviewerApplies')
+);
+
+// Mirror of hasReviewerOverride. Key PRESENCE, not truthiness — an explicitly
+// empty `optionalReviewers: []` is a real override, so `||`-style checks report
+// the wrong answer.
+export function hasReviewerOverride(metadata) {
+ return !!metadata && typeof metadata === 'object' && !Array.isArray(metadata)
+ && REVIEWER_LIST_OVERRIDE_KEYS.some((key) => key in metadata);
+}
+
// Resolve task metadata to an ordered, deduped reviewer list (mirror of the
// server's normalizeReviewers): prefers `reviewers`, falls back to the legacy
// single `reviewer`, defaults to DEFAULT_REVIEWERS.
diff --git a/server/lib/README.md b/server/lib/README.md
index e196918ac0..da7ee06b72 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -143,7 +143,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `antigravity.js` | Antigravity (`agy`) CLI provider helpers — id/sentinel constants (`ANTIGRAVITY_CLI_ID`, `ANTIGRAVITY_CONFIGURED_DEFAULT`, `LEGACY_GEMINI_*`), `isAntigravityCommand`/`isAntigravityCliProvider` predicates, and `ensureAntigravityPrintArgs(args, {model, effort})`/`ensureAntigravityTuiArgs(args, {model, effort})`/`stripAntigravityUnsupportedArgs` argv normalizers. `parseAntigravityModelList(stdout)` parses `agy models` rows — accepts both the modern `\t` shape and the older bare-id-per-line one, deduped, sentinel dropped (mirrored in the vendored toolkit's `internal/antigravity.js`; used by both the provider-catalog refresh and Image Gen's agy model picker). `isAntigravityModelId(id)` is the same id shape as a bare predicate, for spawn sites building an `agy --model` argv from a value no route schema bounded. The strip drops legacy Gemini `--yolo`/`-m`/`--output-format` but PRESERVES the long `--model` (agy accepts it as a per-session flag, so a user-baked pin is a real selection and suppresses the injected one); the two builders inject `--model`/`--effort` from the per-run overrides, always ahead of the trailing `--print` marker whose value is the prompt. |
| `llmText.js` | Pure LLM-output text helpers — `stripCodeFences` (unfence a model reply) and `parseLLMJSON` (unfence then JSON.parse with a descriptive throw). Below the provider layer, so lib can clean model output without importing provider orchestration. |
| `providerCooldown.js` | Provider bench policy — `resolveProviderBench(analysis)` → `null` (don't bench) / a `usage-limit` marker / an `unavailable` marker with the per-category cooldown from `COOLDOWN_MS_BY_CATEGORY`. Declines to bench request/response-specific categories (`isRequestSpecificCategory` / `isSchemaTypeCategory`) so one bad model id or off-shape response can't take a healthy provider offline. Shared by `services/promptRunner.js` (prompt cascade) and `services/agentFinalization.js` (finished CoS agent run) so both bench a category for the same window. Pure. |
-| `reviewerConfig.js` | Review-Loop reviewer vocabulary (split out of `cosValidation.js`, #5702; Zod-free): the reviewer roster + aliases (`REVIEWER_VALUES` / `REVIEWER_ALIASES` / `DEFAULT_REVIEWER`), the local-LLM / PortOS-only / model-capable / effort-selectable subsets, the slug→CLI-binary map (`REVIEWER_CLI_BINARIES`, `isCliReviewer`, `reviewerCliBinary`), the keyed model/effort/max-rounds pin normalizers + resolvers (`normalizeReviewers`, `normalizeReviewerModels`, `normalizeReviewerEfforts`, `resolveReviewerConfig`, `resolveClaimReviewerConfig`, `KEYED_REVIEWER_PINS`), and the emitters that render a reviewer set into slashdo argv (`buildReviewWithArgs`, `buildReviewersCsv`) and into agent-prompt notes (`buildReviewerPinNote`, `buildReviewerEffortNote`). Re-exported flat by `cosValidation.js`, so existing `validation.js` imports keep resolving. |
+| `reviewerConfig.js` | Review-Loop reviewer vocabulary (split out of `cosValidation.js`, #5702; Zod-free): the reviewer roster + aliases (`REVIEWER_VALUES` / `REVIEWER_ALIASES` / `DEFAULT_REVIEWER`), the local-LLM / PortOS-only / model-capable / effort-selectable subsets, the slug→CLI-binary map (`REVIEWER_CLI_BINARIES`, `isCliReviewer`, `reviewerCliBinary`), the keyed model/effort/max-rounds pin normalizers + resolvers (`normalizeReviewers`, `normalizeReviewerModels`, `normalizeReviewerEfforts`, `resolveReviewerConfig`, `resolveClaimReviewerConfig`, `KEYED_REVIEWER_PINS`), the override-key rosters that say when a task has pinned its own reviewers (`REVIEWER_OVERRIDE_KEYS` — everything the picker writes and its reset clears; `REVIEWER_LIST_OVERRIDE_KEYS` / `hasReviewerOverride` — only the keys that can change the resolved LIST), and the emitters that render a reviewer set into slashdo argv (`buildReviewWithArgs`, `buildReviewersCsv`) and into agent-prompt notes (`buildReviewerPinNote`, `buildReviewerEffortNote`). Re-exported flat by `cosValidation.js`, so existing `validation.js` imports keep resolving. |
| `providerPrerequisites.js` | Can a provider run on this host AT ALL — `providerPrerequisites(provider, { runtime, gatewayKeySet })` → `{ met, missing: [{ code, label }] }` (CLI binary on PATH, API key stored for a public endpoint, the sibling key a gateway-backed wrapper inherits from the API record of its own gateway id unless it carries its own), plus `providerRuntimeKey` (`null` for an API provider, for a command carrying an explicit path, and for a provider with its own `PATH` in `envVars` — the runtime table only answers "does the BARE binary resolve on PortOS's PATH?", which is not those providers' question), `isPrivateNetworkEndpoint` (mirror of the client copy: loopback/RFC1918/tailnet CGNAT + ULA/`.ts.net`/single-label hosts need no key) and `describeMissingPrerequisites`. `runtime: null` = NOT PROBED and never counts as missing. Feeds BOTH `GET /api/providers` and the fallback chain in `aiToolkit/providerStatus.js` (via `services/providerPrerequisites.js`), so a `NEEDS SETUP` card and the router read one computation (#4611). `ROUTING_BLOCKING_CODES`/`blocksRouting(missing)` narrow what ROUTING may act on to the missing binary. Stored, inherited, and environment-backed credential findings stay presentation-only here because the server cannot assume the eventual process environment; the client card classifies sanitized env metadata through tri-state lookups (#4612). Pure. |
| `tuiShellLaunch.js` | `buildTuiShellLaunch(provider)` → `{ commandLine, env }` for launching a TUI provider by hand in a Shell session (the AI Providers card's "Launch in Shell" button). Resolves the command via `tuiHandshake.js#buildTuiInvocation` (so the vendor posture flags and `--model`/`--effort` injection match a real TUI spawn) and the env via `cliChildEnv.js#composeProviderEnv`. **The env is why this is server-side and the deep link carries only a provider ID**: a TUI provider's backend lives in `envVars` (`ANTHROPIC_BASE_URL` for an Ollama-backed or Bedrock `claude`, `OPENCODE_CONFIG_CONTENT` for an OpenCode wrapper), so a shell handed only the command line would run the right binary against the vendor cloud instead of the local daemon the user configured — and those values are secret, so they can't ride a URL. Returns null for a non-TUI provider. Must be given a RAW provider, never a client-sanitized one (redacted `'***'` env reads truthy). |
| `tuiHandshake.js` | Shared TUI invocation + paste-handshake constants. Also owns `SUBMIT_KEY` — the single Enter byte every PTY writer sends, whether it's submitting a pasted TUI prompt or a command PortOS injected into a shell session. It is CR, never LF: a POSIX pty's ICRNL hides the difference, but cmd.exe under Windows ConPTY accepts only CR and leaves an LF-terminated line typed-but-unexecuted at the prompt. |
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index d06e1fbbfe..9d361c944d 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -17545,8 +17545,8 @@
],
"stats": {
"mounts": 147,
- "operations": 2172,
- "declarations": 2180,
+ "operations": 2173,
+ "declarations": 2181,
"sourceFiles": 230
}
}
diff --git a/server/lib/reviewerConfig.js b/server/lib/reviewerConfig.js
index ed5042bbf7..24069b97e4 100644
--- a/server/lib/reviewerConfig.js
+++ b/server/lib/reviewerConfig.js
@@ -178,13 +178,10 @@ export function describeReviewerCli(reviewer) {
export const REVIEW_STOP_MODES = ['all', 'on-findings', 'on-clean'];
export const DEFAULT_REVIEW_STOP_MODE = 'all';
-// The task-metadata keys that TOGETHER form a task-local reviewer override. A
-// task type carrying ANY of them has pinned its own reviewers and no longer
-// tracks the install-wide Code Review Defaults — which is the point of the
-// override, and also the reason a claim launched from the UI can name a reviewer
-// the Code Reviewers panel no longer lists. One roster so the resolver, the `source`
-// claim-reviewer lookup reports, and the picker's "Use system Code Review
-// Defaults" reset all agree on what counts as an override.
+// The task-metadata keys that TOGETHER form a task-local reviewer override —
+// everything the picker writes, and so everything its "Use system Code Review
+// Defaults" reset has to remove. A key missing here leaves a pin behind after a
+// reset the user believes cleared it.
// Mirrored in client/src/lib/reviewerPins.js.
export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
// Legacy singular, still stored on schedules saved before the list existed.
@@ -199,14 +196,26 @@ export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
'reviewerApplies',
]);
+// The subset `resolveReviewerConfig` actually READS. `reviewStopMode` and
+// `reviewerApplies` are deliberately absent: they are slashdo run flags, and a
+// claim flow has no flag string to put them in (the claim prompt gets a reviewer
+// CSV), so neither can change which reviewers a claim runs. Keying the reported
+// `source` on the full roster would label a task `task-override` for a stop-mode
+// the user set years ago and then send them to clear an "override" that is not
+// supplying the list they are looking at.
+export const REVIEWER_LIST_OVERRIDE_KEYS = Object.freeze(
+ REVIEWER_OVERRIDE_KEYS.filter((key) => key !== 'reviewStopMode' && key !== 'reviewerApplies')
+);
+
/**
- * Does this task metadata pin its own reviewers? Key PRESENCE is the signal, not
- * truthiness: an explicitly empty `optionalReviewers: []` or `reviewerModels: {}`
- * is a real override (it clears the defaults' value), so collapsing it into
- * "nothing configured" would report the wrong source.
+ * Does this task metadata pin any part of the reviewer list itself (as opposed
+ * to a run flag)? Key PRESENCE is the signal, not truthiness: an explicitly
+ * empty `optionalReviewers: []` or `reviewerModels: {}` is a real override — it
+ * clears the defaults' value — so collapsing it into "nothing configured" would
+ * report the wrong source.
*/
export function hasReviewerOverride(metadata) {
- return isPlainObject(metadata) && REVIEWER_OVERRIDE_KEYS.some((key) => key in metadata);
+ return isPlainObject(metadata) && REVIEWER_LIST_OVERRIDE_KEYS.some((key) => key in metadata);
}
// Arbitrary GitHub reviewer usernames (e.g. `@CodeReviewbot`) requested as PR
diff --git a/server/lib/reviewerConfig.test.js b/server/lib/reviewerConfig.test.js
index 342da4f689..8be39203cc 100644
--- a/server/lib/reviewerConfig.test.js
+++ b/server/lib/reviewerConfig.test.js
@@ -35,6 +35,7 @@ import {
DEFAULT_REVIEWERS,
REVIEW_STOP_MODES,
REVIEWER_OVERRIDE_KEYS,
+ REVIEWER_LIST_OVERRIDE_KEYS,
hasReviewerOverride,
DEFAULT_REVIEW_STOP_MODE,
MAX_REVIEW_USERNAMES,
@@ -444,9 +445,19 @@ describe('client mirror of the reviewer vocabulary', () => {
// the claim-reviewer lookup (server) calls a task an override when any is
// present. A key on one side only means the reset leaves a pin behind while
// the lookup keeps reporting `task-override` for a task the user just cleared.
- it('matches the server reviewer-override key roster', async () => {
+ it('matches the server reviewer-override key rosters and predicate', async () => {
const client = await import('../../client/src/lib/reviewerPins.js');
expect(client.REVIEWER_OVERRIDE_KEYS).toEqual([...REVIEWER_OVERRIDE_KEYS]);
+ expect(client.REVIEWER_LIST_OVERRIDE_KEYS).toEqual([...REVIEWER_LIST_OVERRIDE_KEYS]);
+ // The predicate too, not just the roster: "presence, not truthiness" is the
+ // half that is easy to get wrong, and a client copy that used `||` would
+ // silently disagree about whether an explicitly-cleared pin is an override.
+ for (const metadata of [
+ { reviewers: ['codex'] }, { optionalReviewers: [] }, { reviewerModels: {} },
+ { reviewStopMode: 'on-clean' }, { reviewerApplies: true }, { claimFlow: true }, null, [],
+ ]) {
+ expect(client.hasReviewerOverride(metadata)).toBe(hasReviewerOverride(metadata));
+ }
});
// Stop modes: the client rows carry UI copy, but their `value`s are the enum
@@ -806,22 +817,27 @@ describe('codeReviewDefaultsFromProvider', () => {
// and the user acts on that answer ("clear the override" vs "change the
// defaults"). The interesting cases are the ones truthiness gets wrong.
describe('hasReviewerOverride', () => {
- it('reports an override for any single reviewer key, including the legacy singular', () => {
- for (const key of REVIEWER_OVERRIDE_KEYS) {
+ it('reports an override for any key that can change the resolved list', async () => {
+ for (const key of REVIEWER_LIST_OVERRIDE_KEYS) {
expect(hasReviewerOverride({ [key]: undefined })).toBe(true);
}
- expect(hasReviewerOverride({ reviewer: 'codex' })).toBe(true);
+ });
+
+ it('ignores the two slashdo run flags — a claim has no flag string to put them in', () => {
+ // `resolveReviewerConfig` never reads them, so a task whose only reviewer key
+ // is a stop-mode resolves its whole list from the defaults. Calling that an
+ // override sends the user to clear a pin that is not supplying what they see.
+ expect(REVIEWER_OVERRIDE_KEYS).toContain('reviewStopMode');
+ expect(hasReviewerOverride({ reviewStopMode: 'on-clean', reviewerApplies: true })).toBe(false);
});
it('treats an explicitly EMPTY pin as an override — it clears the defaults value', () => {
// `[]`/`{}` are the shape a user gets by unmarking every optional reviewer or
// clearing every model pin. Gating on truthiness would call that "unset" and
- // send the user to the Code Reviewers panel, which is not where the value
- // they are seeing comes from.
+ // send them to the Code Reviewers panel, which is not where the value they
+ // are seeing comes from.
expect(hasReviewerOverride({ optionalReviewers: [] })).toBe(true);
expect(hasReviewerOverride({ reviewerModels: {} })).toBe(true);
- expect(hasReviewerOverride({ reviewerApplies: false })).toBe(true);
- expect(hasReviewerOverride({ reviewerMaxRounds: {} })).toBe(true);
});
it('reports no override for task metadata that only carries non-reviewer options', () => {
diff --git a/server/routes/apps/taskTypes.js b/server/routes/apps/taskTypes.js
index 5f37ae84f3..64a7099a0e 100644
--- a/server/routes/apps/taskTypes.js
+++ b/server/routes/apps/taskTypes.js
@@ -20,10 +20,9 @@ import { Router } from 'express';
import { logCosScheduleUpdate } from '../../services/userActionScheduleLog.js';
import * as appsService from '../../services/apps.js';
import { PORTOS_APP_ID } from '../../services/apps.js';
-import { sanitizeTaskMetadata, ISSUE_AUTHOR_FILTERS, resolveClaimReviewerConfig, hasReviewerOverride } from '../../lib/validation.js';
+import { sanitizeTaskMetadata, ISSUE_AUTHOR_FILTERS } from '../../lib/validation.js';
import { listWorkItems } from '../../services/workItems.js';
-import { resolveClaimWorkMetadata, resolveClaimAuthorFilter } from '../../services/cosTaskGenerator.js';
-import { getCodeReviewDefaults } from '../../services/codeReview.js';
+import { resolveClaimWorkMetadata, resolveClaimAuthorFilter, resolveAppClaimReviewers } from '../../services/cosTaskGenerator.js';
import { parseCronToNextRun } from '../../services/eventScheduler.js';
import { INTERVAL_TYPES, decodeIntervalType, isCronExpression, isKnownIntervalType } from '../../services/taskScheduleConstants.js';
import { asyncHandler, ServerError } from '../../lib/errorHandler.js';
@@ -99,42 +98,36 @@ router.get('/:id/work-items', loadApp, asyncHandler(async (req, res) => {
}));
// GET /api/apps/:id/claim-reviewers - The reviewers a `/do:next` claim will
-// ACTUALLY run for this app, resolved through the same
-// `resolveClaimWorkMetadata` → `resolveClaimReviewerConfig` chain
-// `buildClaimWorkTask` uses to fill the claim prompt's `{reviewers}` token.
+// ACTUALLY run for this app. Resolved by `resolveAppClaimReviewers`, the same
+// function `buildClaimWorkTask` fills the claim prompt's `{reviewers}` token
+// from, so a preview cannot report a chain the run won't use.
//
-// The manual claim surfaces used to seed their reviewer display straight from
-// `GET /api/code-review/defaults`, which skips the claim-work task metadata
-// layer entirely. A `claim-work` override left over from an earlier reviewer
-// choice therefore ran reviewers the Code Reviewers panel no longer listed, with the
-// drawer confidently showing the panel's list — so the run named `codex` while
-// the UI said `antigravity`. `source` is what makes that visible: `task-override`
-// means an override supplied the list and the install defaults were not
-// consulted, `defaults` means they were.
+// It exists because the two layers disagree in practice: a claim resolves the
+// claim-work task metadata FIRST and only falls back to the install-wide Code
+// Review Defaults, so a stale override there ran codex + claude long after the
+// user had moved the panel to antigravity — while every reviewer control on
+// screen, seeded from `GET /api/code-review/defaults`, showed antigravity.
+// `source` names the layer that won so the UI can send the user to the right one.
//
// Read-only: metadata + settings reads, no claim markers, no LLM call.
router.get('/:id/claim-reviewers', loadApp, asyncHandler(async (req, res) => {
const app = req.loadedApp;
- // Independent reads — the resolver needs both, but neither depends on the other.
- const [{ metadata }, codeReviewDefaults] = await Promise.all([
- resolveClaimWorkMetadata(app),
- // A settings read failure means "no configured defaults", never a failed
- // lookup: the resolver still answers from the task metadata, which is the
- // layer that wins anyway.
- getCodeReviewDefaults().catch(() => null)
- ]);
- const config = resolveClaimReviewerConfig(metadata, codeReviewDefaults, codeReviewDefaults?.reviewers);
+ const { overridden, reviewers, usernames, optionalReviewers, reviewerMaxRounds, reviewerModels, reviewerEfforts, csv } =
+ await resolveAppClaimReviewers(app);
+ // Spelled out rather than spread: `resolveClaimReviewerConfig` also carries
+ // `stopMode` / `reviewerApplies`, which a claim flow has no flag string to put
+ // them in — publishing them would advertise a contract this route can't keep.
res.json({
appId: app.id,
appName: app.name,
- source: hasReviewerOverride(metadata) ? 'task-override' : 'defaults',
- reviewers: config.reviewers,
- usernames: config.usernames,
- optionalReviewers: config.optionalReviewers,
- reviewerMaxRounds: config.reviewerMaxRounds,
- reviewerModels: config.reviewerModels,
- reviewerEfforts: config.reviewerEfforts,
- csv: config.csv
+ source: overridden ? 'task-override' : 'defaults',
+ reviewers,
+ usernames,
+ optionalReviewers,
+ reviewerMaxRounds,
+ reviewerModels,
+ reviewerEfforts,
+ csv
});
}));
diff --git a/server/routes/apps/taskTypes.test.js b/server/routes/apps/taskTypes.test.js
index feb70ec192..1f43156303 100644
--- a/server/routes/apps/taskTypes.test.js
+++ b/server/routes/apps/taskTypes.test.js
@@ -34,20 +34,14 @@ vi.mock('../../services/workItems.js', () => ({
}));
vi.mock('../../services/cosTaskGenerator.js', async (importActual) => ({
...(await importActual()),
- resolveClaimWorkMetadata: vi.fn()
-}));
-// Only the settings READ is mocked; resolveClaimReviewerConfig (the layer
-// precedence, the copilot guard, and the emitted CSV) runs for real, so these
-// assert the reviewers a claim would genuinely get rather than a restated stub.
-vi.mock('../../services/codeReview.js', () => ({
- getCodeReviewDefaults: vi.fn()
+ resolveClaimWorkMetadata: vi.fn(),
+ resolveAppClaimReviewers: vi.fn()
}));
import * as appsService from '../../services/apps.js';
import { listOutcomesResult } from '../../services/layeredIntelligenceOutcomes.js';
import { listWorkItems } from '../../services/workItems.js';
-import { resolveClaimWorkMetadata } from '../../services/cosTaskGenerator.js';
-import { getCodeReviewDefaults } from '../../services/codeReview.js';
+import { resolveClaimWorkMetadata, resolveAppClaimReviewers } from '../../services/cosTaskGenerator.js';
describe('Apps Task-Type Routes', () => {
let app;
@@ -122,70 +116,58 @@ describe('Apps Task-Type Routes', () => {
});
});
- // The reviewer chain a manual claim runs is resolved from TWO layers, and the
- // whole point of this route is that the second one can be invisible: a
- // claim-work task override wins over the install-wide Code Review Defaults, so
- // a UI seeded from the defaults alone shows reviewers the run will not use.
+ // The route's own job is narrow: map the resolver's `overridden` boolean to the
+ // `source` label the UI acts on, and publish only the fields a claim flow can
+ // honor. The reviewer RESOLUTION it previews (layer precedence, copilot guard,
+ // emitted CSV) belongs to `resolveAppClaimReviewers`, which the claim builder
+ // shares — covered in cosTaskGenerator.test.js and reviewerConfig.test.js.
describe('GET /api/apps/:id/claim-reviewers', () => {
+ const RESOLVED = {
+ reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude',
+ // resolveClaimReviewerConfig also carries these two, and a claim flow has no
+ // slashdo flag string to put them in — the route must not publish them.
+ stopMode: 'all', reviewerApplies: false
+ };
+
beforeEach(() => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'App' });
- getCodeReviewDefaults.mockResolvedValue({ reviewers: ['antigravity'], antigravityModel: 'gemini-3.8-flash' });
});
- it('answers from the Code Review Defaults, marked `defaults`, when the task pins nothing', async () => {
- resolveClaimWorkMetadata.mockResolvedValue({ metadata: { useWorktree: false, claimFlow: true }, interval: {} });
-
- const response = await request(app).get('/api/apps/app-001/claim-reviewers');
-
- expect(response.status).toBe(200);
- expect(response.body.source).toBe('defaults');
- expect(response.body.reviewers).toEqual(['antigravity']);
- expect(response.body.csv).toBe('antigravity[gemini-3.8-flash]');
- });
-
- it('reports the claim-work override that WINS over the defaults, marked `task-override`', async () => {
+ it('reports `task-override` so the UI sends the user to the override, not the defaults panel', async () => {
// The #6202 shape: the install default had been moved to `antigravity`, but a
// claim-work override saved months earlier still named codex + claude, so
// every manual claim reviewed with those while every reviewer control on
// screen showed `antigravity`.
- resolveClaimWorkMetadata.mockResolvedValue({
- metadata: { claimFlow: true, reviewers: ['codex', 'claude'] },
- interval: {}
- });
+ resolveAppClaimReviewers.mockResolvedValue({ ...RESOLVED, overridden: true });
const response = await request(app).get('/api/apps/app-001/claim-reviewers');
expect(response.status).toBe(200);
- expect(response.body.source).toBe('task-override');
- expect(response.body.reviewers).toEqual(['codex', 'claude']);
- expect(response.body.csv).toBe('codex,claude');
+ expect(resolveAppClaimReviewers).toHaveBeenCalledWith({ id: 'app-001', name: 'App' });
+ expect(response.body).toMatchObject({
+ appId: 'app-001', source: 'task-override', reviewers: ['codex', 'claude'], csv: 'codex,claude'
+ });
});
- it('drops copilot from a claim chain, matching what the claim prompt is given', async () => {
- // claimSafeReviewers: copilot has no CLI, so a claim agent told to review
- // with it stalls. The lookup has to show the SAME post-guard list the
- // prompt gets, or the UI advertises a reviewer the run silently removes.
- resolveClaimWorkMetadata.mockResolvedValue({
- metadata: { reviewers: ['copilot', 'claude'] },
- interval: {}
+ it('reports `defaults` when nothing overrode them', async () => {
+ resolveAppClaimReviewers.mockResolvedValue({
+ ...RESOLVED, overridden: false, reviewers: ['antigravity'], csv: 'antigravity[gemini-3.8-flash]'
});
const response = await request(app).get('/api/apps/app-001/claim-reviewers');
- expect(response.body.reviewers).toEqual(['claude']);
+ expect(response.body.source).toBe('defaults');
+ expect(response.body.csv).toBe('antigravity[gemini-3.8-flash]');
});
- it('still answers from the task override when the settings read fails', async () => {
- // A failed settings read means "no configured defaults", never a failed
- // lookup — the layer that wins is the task metadata either way.
- getCodeReviewDefaults.mockRejectedValue(new Error('settings unavailable'));
- resolveClaimWorkMetadata.mockResolvedValue({ metadata: { reviewers: ['grok'] }, interval: {} });
+ it('does not publish the run flags a claim flow has nowhere to put', async () => {
+ resolveAppClaimReviewers.mockResolvedValue({ ...RESOLVED, overridden: false });
const response = await request(app).get('/api/apps/app-001/claim-reviewers');
- expect(response.status).toBe(200);
- expect(response.body.source).toBe('task-override');
- expect(response.body.reviewers).toEqual(['grok']);
+ expect(response.body.stopMode).toBeUndefined();
+ expect(response.body.reviewerApplies).toBeUndefined();
});
});
diff --git a/server/services/cosTaskGenerator.js b/server/services/cosTaskGenerator.js
index ce5b149e0f..9577fa3c94 100644
--- a/server/services/cosTaskGenerator.js
+++ b/server/services/cosTaskGenerator.js
@@ -26,7 +26,7 @@
import { readFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
-import { sanitizeTaskMetadata, PIPELINE_STAGE_BEHAVIOR_FLAGS, MAX_TOTAL_SPAWNS, resolveClaimReviewerConfig, reviewerConfigMetadata } from '../lib/validation.js';
+import { sanitizeTaskMetadata, PIPELINE_STAGE_BEHAVIOR_FLAGS, MAX_TOTAL_SPAWNS, resolveClaimReviewerConfig, reviewerConfigMetadata, hasReviewerOverride } from '../lib/validation.js';
import { PATHS } from '../lib/fileUtils.js';
import { MODEL_ABUSE_GUARD_ID, normalizeEligibilityFacts } from '../lib/modelAbuseGuard.js';
import { isPlainObject } from '../lib/objects.js';
@@ -333,6 +333,53 @@ export function resolveClaimAuthorFilter(explicit, metadata) {
return explicit ?? metadata?.issueAuthorFilter ?? 'self';
}
+/**
+ * The reviewer bundle a claim will run: an explicit option wins per field, then
+ * the app's configured `claim-work` metadata, then the Code Review Defaults. One
+ * resolver for the whole bundle (list + usernames + `~opt` set + the three keyed
+ * pins), so the CSV the prompt names and the `reviewers` the task PERSISTS cannot
+ * disagree. Local-LLM reviewers stay in the operative list; the claim prompt's
+ * appended Local Reviewer Procedure tells the agent how to invoke PortOS's review
+ * service rather than silently replacing the user's configured reviewer.
+ *
+ * Shared with `resolveAppClaimReviewers` (and through it the claim-reviewer
+ * lookup route) precisely so a change to this precedence reaches the preview the
+ * UI shows and the run it previews at the same time — the two drifting apart is
+ * the whole defect that lookup exists to close.
+ */
+function claimReviewersFrom(metadata, codeReviewDefaults, explicit = {}) {
+ const { reviewers, usernames, optionalReviewers, reviewerMaxRounds, reviewerModels, reviewerEfforts } = explicit;
+ return resolveClaimReviewerConfig({
+ ...metadata,
+ reviewers: reviewers !== undefined ? (Array.isArray(reviewers) ? reviewers : [reviewers]) : metadata?.reviewers,
+ usernames: usernames ?? metadata?.usernames,
+ optionalReviewers: optionalReviewers ?? metadata?.optionalReviewers,
+ reviewerMaxRounds: reviewerMaxRounds ?? metadata?.reviewerMaxRounds,
+ reviewerModels: reviewerModels ?? metadata?.reviewerModels,
+ reviewerEfforts: reviewerEfforts ?? metadata?.reviewerEfforts
+ }, codeReviewDefaults, codeReviewDefaults?.reviewers);
+}
+
+/**
+ * What a `/do:next` claim would resolve for `app` right now, without queuing one:
+ * the reviewer bundle plus `overridden`, which says whether the claim-work task
+ * metadata supplied any of the list (so the UI can send the user to the override
+ * rather than to the Code Reviewers panel that isn't in play).
+ *
+ * Reads the same two layers `buildClaimWorkTask` does and hands them to the same
+ * `claimReviewersFrom`, so the preview cannot report a chain the run won't use.
+ */
+export async function resolveAppClaimReviewers(app) {
+ // Independent reads — the resolver needs both, but neither depends on the other.
+ const [{ metadata }, codeReviewDefaults] = await Promise.all([
+ resolveClaimWorkMetadata(app),
+ // A settings read failure means "no configured defaults", never a failed
+ // lookup: the task metadata layer wins over them anyway.
+ getCodeReviewDefaults().catch(() => null)
+ ]);
+ return { ...claimReviewersFrom(metadata, codeReviewDefaults), overridden: hasReviewerOverride(metadata) };
+}
+
/**
* Build a one-off "claim the next work item" task for `app`, routed by the app's
* configured workTracker — the manual (Slashdo `/do:next` button) counterpart to
@@ -399,22 +446,9 @@ export async function buildClaimWorkTask(app, {
const resolvedAuthorFilter = resolveClaimAuthorFilter(issueAuthorFilter, metadata);
- // Reviewers: an explicit option wins per field, then the app's configured
- // claim-work metadata, then the Code Review Defaults. One resolver for the
- // whole bundle (list + usernames + `~opt` set + the three keyed pins), so the
- // CSV the prompt names and the `reviewers` this task PERSISTS below cannot
- // disagree. Local-LLM reviewers stay in the operative list; an appended
- // procedure below tells the claim agent how to invoke PortOS's review service
- // instead of silently replacing the user's configured reviewer.
- const claimReviewers = resolveClaimReviewerConfig({
- ...metadata,
- reviewers: reviewers !== undefined ? (Array.isArray(reviewers) ? reviewers : [reviewers]) : metadata.reviewers,
- usernames: usernames ?? metadata.usernames,
- optionalReviewers: optionalReviewers ?? metadata.optionalReviewers,
- reviewerMaxRounds: reviewerMaxRounds ?? metadata.reviewerMaxRounds,
- reviewerModels: reviewerModels ?? metadata.reviewerModels,
- reviewerEfforts: reviewerEfforts ?? metadata.reviewerEfforts
- }, codeReviewDefaults, codeReviewDefaults?.reviewers);
+ const claimReviewers = claimReviewersFrom(metadata, codeReviewDefaults, {
+ reviewers, usernames, optionalReviewers, reviewerMaxRounds, reviewerModels, reviewerEfforts
+ });
const {
reviewers: reviewersList,
reviewerModels: promptReviewerModels,
diff --git a/server/services/cosTaskGenerator.test.js b/server/services/cosTaskGenerator.test.js
index 1d524cc9af..2a7d7c6979 100644
--- a/server/services/cosTaskGenerator.test.js
+++ b/server/services/cosTaskGenerator.test.js
@@ -77,6 +77,7 @@ import {
recordPerpetualTransient,
buildJiraTicketTask,
buildClaimWorkTask,
+ resolveAppClaimReviewers,
buildImprovementDedupSets,
queueDueInstallWideImprovementTasks,
normalizeWorkItemRef,
@@ -503,7 +504,7 @@ describe('{reviewers} interpolation honors Code Review Defaults', () => {
// run if the CSV carries it. Both the scheduled path and buildClaimWorkTask
// feed the cap into the shared claim resolver, which applies task-over-default
// precedence (unit-tested in reviewerConfig.test.js).
- expect(GEN_SRC).toContain('reviewerMaxRounds: reviewerMaxRounds ?? metadata.reviewerMaxRounds');
+ expect(GEN_SRC).toContain('reviewerMaxRounds: reviewerMaxRounds ?? metadata?.reviewerMaxRounds');
expect(GEN_SRC).toContain('resolveClaimReviewerConfig(metadata, codeReviewDefaults, codeReviewDefaults?.reviewers)');
expect(GEN_SRC).not.toContain('resolveReviewerMaxRounds(');
});
@@ -519,8 +520,8 @@ describe('{reviewers} interpolation honors Code Review Defaults', () => {
// agy model id can carry its effort as a suffix, so a path that resolved the
// models alone would emit `--model --effort `, a pair agy
// rejects, while the other paths emitted the split form.
- expect(GEN_SRC).toContain('reviewerModels: reviewerModels ?? metadata.reviewerModels');
- expect(GEN_SRC).toContain('reviewerEfforts: reviewerEfforts ?? metadata.reviewerEfforts');
+ expect(GEN_SRC).toContain('reviewerModels: reviewerModels ?? metadata?.reviewerModels');
+ expect(GEN_SRC).toContain('reviewerEfforts: reviewerEfforts ?? metadata?.reviewerEfforts');
// The play-button path reads the defaults directly (no task metadata to layer).
expect(GEN_SRC).toContain('resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers)');
// No path may resolve one map without the other — or reach past the shared
@@ -603,8 +604,13 @@ describe('claim-work single-source routing', () => {
// Reviewers layer an explicit per-field option over the configured claim-work
// metadata, then fall back to the Code Review Defaults — through the claim
// resolver, which keeps local LLMs and excludes the retired Copilot path.
- expect(fn).toMatch(/resolveClaimReviewerConfig\(\{\s*\.\.\.metadata,/);
- expect(fn).toMatch(/reviewers: reviewers !== undefined/);
+ // `claimReviewersFrom` owns that layering for BOTH the builder and the
+ // claim-reviewer lookup route, so the preview the UI shows and the run it
+ // previews can't resolve differently.
+ expect(fn).toMatch(/claimReviewersFrom\(metadata, codeReviewDefaults, \{/);
+ const layering = GEN_SRC.slice(GEN_SRC.indexOf('function claimReviewersFrom('));
+ expect(layering).toMatch(/resolveClaimReviewerConfig\(\{\s*\.\.\.metadata,/);
+ expect(layering).toMatch(/reviewers: reviewers !== undefined/);
expect(fn).toMatch(/buildLocalReviewerInstructions\(reviewersList/);
// A direct claim-work prompt customization overrides the tracker body, same
// as the scheduled router's promptKeyForBody selection.
@@ -1768,3 +1774,59 @@ describe('buildClaimWorkTask reviewer pin', () => {
expect(taskMetadata.swarmCount).toBeUndefined();
});
});
+
+// The read-only preview behind `GET /api/apps/:id/claim-reviewers`. It shares
+// `claimReviewersFrom` with buildClaimWorkTask on purpose: a preview that
+// resolved differently from the run is the exact defect it exists to close, so
+// these assert the RESOLUTION, not that a mock was called.
+//
+// This file's mocks put the interesting disagreement in place already —
+// claim-work metadata pins `['codex', 'claude']` while the Code Review Defaults
+// say `['ollama']`, which is the #6202 shape.
+describe('resolveAppClaimReviewers', () => {
+ const APP = { id: 'app-1', name: 'App', repoPath: '/repo' };
+
+ it('returns the claim-work override that WINS, and flags it as the source', async () => {
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['codex', 'claude']);
+ expect(result.csv).toBe('codex,claude,@alice');
+ // `overridden` is what the UI turns into "clear the override" vs "change the
+ // Code Reviewers panel" — getting it backwards sends the user to a control
+ // that isn't supplying what they're looking at.
+ expect(result.overridden).toBe(true);
+ });
+
+ it('falls through to the Code Review Defaults, unflagged, when claim-work pins no list', async () => {
+ getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { issueAuthorFilter: 'owner' } });
+
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['ollama']);
+ expect(result.overridden).toBe(false);
+ });
+
+ it('does not call a stop-mode-only task an override — it cannot change the list', async () => {
+ // A claim prompt gets a reviewer CSV, not a slashdo flag string, so
+ // `reviewStopMode` reaches nothing. Reporting it as the source would send the
+ // user to clear an override that is not supplying the reviewers they see.
+ getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { reviewStopMode: 'on-clean', reviewerApplies: true } });
+
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['ollama']);
+ expect(result.overridden).toBe(false);
+ });
+
+ it('strips copilot, matching the list the claim prompt is actually given', async () => {
+ // claimSafeReviewers: copilot has no CLI, so a claim agent told to review
+ // with it stalls (#2507). The preview must show the post-guard list or it
+ // advertises a reviewer the run silently removes.
+ getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { reviewers: ['copilot', 'claude'] } });
+
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['claude']);
+ expect(result.overridden).toBe(true);
+ });
+});
From 24b00441c087137aa559f99a43bde83cd8630315 Mon Sep 17 00:00:00 2001
From: "[._.]/ Adam Eivy"
Date: Fri, 4 Sep 2026 05:44:04 +0000
Subject: [PATCH 3/3] fix three false claims the review-gate caught in the
claim-reviewer copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ClaimReviewerSource sent the user to "this app's Automation tab" to clear the
override. The reviewer picker is rendered only by GlobalConfigControls, reached
only through Chief of Staff → Schedule; the Automation tab has no such control,
so that half of the sentence was a dead end. Names the one screen that works.
- The apiApps doc said `task-override` means the defaults "were never consulted".
resolveReviewerConfig falls back per FIELD, so a task pinning only `reviewers`
still takes its models and usernames from the defaults.
- IssuesTab carried an empty-reviewers branch saying a Claim "will merge without
one". claimSafeReviewers never returns an empty list, so the branch was
unreachable and its claim was wrong either way.
---
client/src/components/apps/ClaimReviewerSource.jsx | 13 ++++++++-----
client/src/components/apps/SlashDoRunDrawer.jsx | 2 +-
client/src/components/apps/tabs/IssuesTab.jsx | 11 +++++------
client/src/services/apiApps.js | 12 +++++++-----
4 files changed, 21 insertions(+), 17 deletions(-)
diff --git a/client/src/components/apps/ClaimReviewerSource.jsx b/client/src/components/apps/ClaimReviewerSource.jsx
index ee55075798..e21019dfaa 100644
--- a/client/src/components/apps/ClaimReviewerSource.jsx
+++ b/client/src/components/apps/ClaimReviewerSource.jsx
@@ -10,17 +10,20 @@ import { Link } from 'react-router';
* actually supplying it — and being sent to the wrong panel is the same class of
* confusion as not being told at all.
*
- * The override lives on the `claim-work` task, editable both globally (Chief of
- * Staff → Schedule) and per app (the app's Automation tab), so the copy names
- * the task rather than a single screen.
+ * The link target is deliberate and verified: the reviewer picker (and the "Use
+ * system Code Review Defaults" reset beside it) is rendered ONLY by
+ * `GlobalConfigControls`, reachable only through Chief of Staff → Schedule's
+ * TaskConfigDrawer. The per-app `claim-work` override the server merges on top
+ * carries reviewer keys too, but the app's Automation tab has no picker for
+ * them — naming it here would send the user to a screen with no such control.
*/
export default function ClaimReviewerSource({ source }) {
if (source === 'task-override') {
return (
<>
- {' — from the '}claim-work {' reviewer override ('}
+ {' — from the '}claim-work {' reviewer override in '}
Chief of Staff → Schedule
- {', or this app’s Automation tab), not Models → Code Reviewers. Clear it there to follow the install default again.'}
+ {', not Models → Code Reviewers. Clear it there to follow the install default again.'}
>
);
}
diff --git a/client/src/components/apps/SlashDoRunDrawer.jsx b/client/src/components/apps/SlashDoRunDrawer.jsx
index a373533acd..6af617d78d 100644
--- a/client/src/components/apps/SlashDoRunDrawer.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.jsx
@@ -187,7 +187,7 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
where it came from. */}
{!review && claimReviewers?.source === 'task-override' && (
- These come
+ Seeded
)}
>
diff --git a/client/src/components/apps/tabs/IssuesTab.jsx b/client/src/components/apps/tabs/IssuesTab.jsx
index 07510c8129..f01032c1c2 100644
--- a/client/src/components/apps/tabs/IssuesTab.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.jsx
@@ -589,13 +589,12 @@ export default function IssuesTab({ appId, appName }) {
Reviewed by
+ {/* No empty-list branch: the route resolves through
+ `claimSafeReviewers`, which falls back to a non-empty list rather
+ than ever handing a claim agent nothing to run. */}
- {claimReviewers.reviewers.length ? (
- <>
- {claimReviewers.csv}
-
- >
- ) : 'No reviewers resolve for this app — a Claim will merge without one.'}
+ {claimReviewers.csv}
+
)}
diff --git a/client/src/services/apiApps.js b/client/src/services/apiApps.js
index 57a5b12859..cb553e80c2 100644
--- a/client/src/services/apiApps.js
+++ b/client/src/services/apiApps.js
@@ -34,12 +34,14 @@ export const getAppWorkItems = (id, { issueAuthorFilter } = {}, options) => {
return request(`/apps/${id}/work-items${qs}`, { silent: true, ...options });
};
// The reviewers a `/do:next` claim will ACTUALLY run for this app, resolved
-// server-side through the claim-work task metadata AND the install-wide Code
-// Review Defaults: `{ source, reviewers, usernames, optionalReviewers,
+// server-side through the claim-work task metadata layered OVER the install-wide
+// Code Review Defaults: `{ source, reviewers, usernames, optionalReviewers,
// reviewerMaxRounds, reviewerModels, reviewerEfforts, csv }`. `source` is
-// 'task-override' when a claim-work override supplied the list (the defaults
-// were never consulted) or 'defaults' when it came from Settings → Code
-// Reviewers. Seed a claim surface from THIS, not from getCodeReviewDefaults —
+// 'task-override' when the claim-work override supplied any part of the list,
+// else 'defaults'. Note the fallback is per FIELD — a task pinning only
+// `reviewers` still takes its models and usernames from the defaults — so
+// 'task-override' means "an override is in play", not "the defaults were
+// ignored". Seed a claim surface from THIS, not from getCodeReviewDefaults —
// the latter can't see the override and so shows reviewers the run won't use.
// Read-only; callers own their fallback, so default to silent.
export const getAppClaimReviewers = (id, options) =>