feat: per-issue assignee adapter overrides (model, effort, workspace)

Add assigneeAdapterOverrides JSONB column to issues, allowing per-issue
model, thinking effort, and workspace overrides when assigning to agents.
Heartbeat service merges overrides into adapter config at runtime. New
Issue dialog exposes these options for Claude and Codex adapters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 10:32:44 -06:00
parent 1e11806fa3
commit e4e5609132
10 changed files with 5899 additions and 6 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "issues" ADD COLUMN "assignee_adapter_overrides" jsonb;

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,13 @@
"when": 1772032176413,
"tag": "0020_white_anita_blake",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1772122471656,
"tag": "0021_chief_vindicator",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,7 @@ import {
text,
timestamp,
integer,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
@@ -38,6 +39,7 @@ export const issues = pgTable(
identifier: text("identifier"),
requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),

View File

@@ -77,6 +77,7 @@ export type {
ProjectGoalRef,
ProjectWorkspace,
Issue,
IssueAssigneeAdapterOverrides,
IssueComment,
IssueAttachment,
IssueLabel,

View File

@@ -13,6 +13,7 @@ export type { AssetImage } from "./asset.js";
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
Issue,
IssueAssigneeAdapterOverrides,
IssueComment,
IssueAncestor,
IssueAncestorProject,

View File

@@ -43,6 +43,11 @@ export interface IssueLabel {
updatedAt: Date;
}
export interface IssueAssigneeAdapterOverrides {
adapterConfig?: Record<string, unknown>;
useProjectWorkspace?: boolean;
}
export interface Issue {
id: string;
companyId: string;
@@ -66,6 +71,7 @@ export interface Issue {
identifier: string | null;
requestDepth: number;
billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
startedAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;

View File

@@ -1,6 +1,13 @@
import { z } from "zod";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
export const issueAssigneeAdapterOverridesSchema = z
.object({
adapterConfig: z.record(z.unknown()).optional(),
useProjectWorkspace: z.boolean().optional(),
})
.strict();
export const createIssueSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
@@ -13,6 +20,7 @@ export const createIssueSchema = z.object({
assigneeUserId: z.string().optional().nullable(),
requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(),
});

View File

@@ -68,10 +68,33 @@ interface WakeupOptions {
contextSnapshot?: Record<string, unknown>;
}
interface ParsedIssueAssigneeAdapterOverrides {
adapterConfig: Record<string, unknown> | null;
useProjectWorkspace: boolean | null;
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function parseIssueAssigneeAdapterOverrides(
raw: unknown,
): ParsedIssueAssigneeAdapterOverrides | null {
const parsed = parseObject(raw);
const parsedAdapterConfig = parseObject(parsed.adapterConfig);
const adapterConfig =
Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null;
const useProjectWorkspace =
typeof parsed.useProjectWorkspace === "boolean"
? parsed.useProjectWorkspace
: null;
if (!adapterConfig && useProjectWorkspace === null) return null;
return {
adapterConfig,
useProjectWorkspace,
};
}
function deriveTaskKey(
contextSnapshot: Record<string, unknown> | null | undefined,
payload: Record<string, unknown> | null | undefined,
@@ -344,6 +367,7 @@ export function heartbeatService(db: Db) {
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null },
) {
const issueId = readNonEmptyString(context.issueId);
const contextProjectId = readNonEmptyString(context.projectId);
@@ -355,15 +379,17 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0]?.projectId ?? null)
: null;
const resolvedProjectId = issueProjectId ?? contextProjectId;
const useProjectWorkspace = opts?.useProjectWorkspace !== false;
const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null;
const projectWorkspaceRows = resolvedProjectId
const projectWorkspaceRows = workspaceProjectId
? await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, resolvedProjectId),
eq(projectWorkspaces.projectId, workspaceProjectId),
),
)
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
@@ -891,13 +917,35 @@ export function heartbeatService(db: Db) {
const context = parseObject(run.contextSnapshot);
const taskKey = deriveTaskKey(context, null);
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const issueId = readNonEmptyString(context.issueId);
const issueAssigneeConfig = issueId
? await db
.select({
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
const issueAssigneeOverrides =
issueAssigneeConfig && issueAssigneeConfig.assigneeAgentId === agent.id
? parseIssueAssigneeAdapterOverrides(
issueAssigneeConfig.assigneeAdapterOverrides,
)
: null;
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
);
const resolvedWorkspace = await resolveWorkspaceForRun(agent, context, previousSessionParams);
const resolvedWorkspace = await resolveWorkspaceForRun(
agent,
context,
previousSessionParams,
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
);
context.paperclipWorkspace = {
cwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
@@ -1016,9 +1064,12 @@ export function heartbeatService(db: Db) {
};
const config = parseObject(agent.adapterConfig);
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...config, ...issueAssigneeOverrides.adapterConfig }
: config;
const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
config,
mergedConfig,
);
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
await appendRunEvent(currentRun, seq++, {

View File

@@ -22,6 +22,8 @@ import {
Maximize2,
Minimize2,
MoreHorizontal,
ChevronRight,
ChevronDown,
CircleDot,
Minus,
ArrowUp,
@@ -47,6 +49,58 @@ interface IssueDraft {
priority: string;
assigneeId: string;
projectId: string;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeUseProjectWorkspace: boolean;
}
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
const ISSUE_THINKING_EFFORT_OPTIONS = {
claude_local: [
{ value: "", label: "Default" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
codex_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
} as const;
function buildAssigneeAdapterOverrides(input: {
adapterType: string | null | undefined;
modelOverride: string;
thinkingEffortOverride: string;
useProjectWorkspace: boolean;
}): Record<string, unknown> | null {
const adapterType = input.adapterType ?? null;
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
return null;
}
const adapterConfig: Record<string, unknown> = {};
if (input.modelOverride) adapterConfig.model = input.modelOverride;
if (input.thinkingEffortOverride) {
if (adapterType === "codex_local") {
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
} else if (adapterType === "claude_local") {
adapterConfig.effort = input.thinkingEffortOverride;
}
}
const overrides: Record<string, unknown> = {};
if (Object.keys(adapterConfig).length > 0) {
overrides.adapterConfig = adapterConfig;
}
if (!input.useProjectWorkspace) {
overrides.useProjectWorkspace = false;
}
return Object.keys(overrides).length > 0 ? overrides : null;
}
function loadDraft(): IssueDraft | null {
@@ -93,6 +147,10 @@ export function NewIssueDialog() {
const [priority, setPriority] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [projectId, setProjectId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
const [expanded, setExpanded] = useState(false);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -117,6 +175,17 @@ export function NewIssueDialog() {
enabled: !!selectedCompanyId && newIssueOpen,
});
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
);
const { data: assigneeAdapterModels } = useQuery({
queryKey: ["adapter-models", assigneeAdapterType],
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides,
});
const createIssue = useMutation({
mutationFn: (data: Record<string, unknown>) =>
issuesApi.create(selectedCompanyId!, data),
@@ -157,8 +226,30 @@ export function NewIssueDialog() {
// Save draft on meaningful changes
useEffect(() => {
if (!newIssueOpen) return;
scheduleSave({ title, description, status, priority, assigneeId, projectId });
}, [title, description, status, priority, assigneeId, projectId, newIssueOpen, scheduleSave]);
scheduleSave({
title,
description,
status,
priority,
assigneeId,
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeUseProjectWorkspace,
});
}, [
title,
description,
status,
priority,
assigneeId,
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeUseProjectWorkspace,
newIssueOpen,
scheduleSave,
]);
// Restore draft or apply defaults when dialog opens
useEffect(() => {
@@ -172,14 +263,38 @@ export function NewIssueDialog() {
setPriority(draft.priority);
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
} else {
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeUseProjectWorkspace(true);
}
}, [newIssueOpen, newIssueDefaults]);
useEffect(() => {
if (!supportsAssigneeOverrides) {
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeUseProjectWorkspace(true);
return;
}
const validThinkingValues =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
setAssigneeThinkingEffort("");
}
}, [supportsAssigneeOverrides, assigneeAdapterType, assigneeThinkingEffort]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
@@ -194,6 +309,10 @@ export function NewIssueDialog() {
setPriority("");
setAssigneeId("");
setProjectId("");
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeUseProjectWorkspace(true);
setExpanded(false);
}
@@ -205,6 +324,12 @@ export function NewIssueDialog() {
function handleSubmit() {
if (!selectedCompanyId || !title.trim()) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort,
useProjectWorkspace: assigneeUseProjectWorkspace,
});
createIssue.mutate({
title: title.trim(),
description: description.trim() || undefined,
@@ -212,6 +337,7 @@ export function NewIssueDialog() {
priority: priority || "medium",
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
...(projectId ? { projectId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
});
}
@@ -242,6 +368,16 @@ export function NewIssueDialog() {
const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
const currentProject = (projects ?? []).find((p) => p.id === projectId);
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
? "Claude options"
: assigneeAdapterType === "codex_local"
? "Codex options"
: "Agent options";
const thinkingEffortOptions =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
(agents ?? [])
@@ -262,6 +398,15 @@ export function NewIssueDialog() {
})),
[projects],
);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() =>
(assigneeAdapterModels ?? []).map((model) => ({
id: model.id,
label: model.label,
searchText: model.id,
})),
[assigneeAdapterModels],
);
return (
<Dialog
@@ -419,6 +564,68 @@ export function NewIssueDialog() {
</div>
</div>
{supportsAssigneeOverrides && (
<div className="px-4 pb-2 shrink-0">
<button
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setAssigneeOptionsOpen((open) => !open)}
>
{assigneeOptionsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{assigneeOptionsTitle}
</button>
{assigneeOptionsOpen && (
<div className="mt-2 rounded-md border border-border p-3 bg-muted/20 space-y-3">
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Model</div>
<InlineEntitySelector
value={assigneeModelOverride}
options={modelOverrideOptions}
placeholder="Default model"
noneLabel="Default model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
onChange={setAssigneeModelOverride}
/>
</div>
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Thinking effort</div>
<div className="flex items-center gap-1.5 flex-wrap">
{thinkingEffortOptions.map((option) => (
<button
key={option.value || "default"}
className={cn(
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
assigneeThinkingEffort === option.value && "bg-accent"
)}
onClick={() => setAssigneeThinkingEffort(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Use project workspace</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeUseProjectWorkspace ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeUseProjectWorkspace((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeUseProjectWorkspace ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
</div>
)}
</div>
)}
{/* Description */}
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
<MarkdownEditor