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

@@ -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++, {