feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker

This commit is contained in:
Gabriel Brown
2026-07-11 10:58:05 -04:00
parent 70396feccc
commit c0f107c4cf
7 changed files with 250 additions and 32 deletions
+31 -6
View File
@@ -4,6 +4,8 @@ import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
import type { AgentRuntimeName } from './runtimeSupport';
import { runtimesForProfile } from './runtimeSupport';
import { assertWorkerToken } from './workerAuth';
const jobStatus = v.union(
@@ -19,7 +21,11 @@ const jobStatus = v.union(
v.literal('timed_out'),
);
const runtime = v.literal('opencode');
const runtime = v.union(
v.literal('codex'),
v.literal('opencode'),
v.literal('claude'),
);
const jobType = v.union(
v.literal('user_change'),
@@ -141,7 +147,6 @@ const maintenanceRisk = v.union(
const defaultAgentSettings = {
enabled: true,
runtime: 'opencode' as const,
branchPrefix: 'spoon/agent',
agentModel: '',
reasoningEffort: 'medium' as const,
@@ -440,7 +445,7 @@ const insertJob = async (
| 'conflict_resolution';
baseBranch?: string;
requestedBranchName?: string;
requestedRuntime?: 'opencode';
requestedRuntime?: AgentRuntimeName;
materializeEnvFile?: boolean;
requestedEnvFilePath?: string;
requestedProfileId?: Id<'aiProviderProfiles'>;
@@ -464,7 +469,23 @@ const insertJob = async (
const now = Date.now();
const resolvedBaseBranch =
optionalText(baseBranch) ?? settings.defaultBaseBranch;
const jobRuntime = requestedRuntime ?? 'opencode';
const supported = runtimesForProfile(profile);
// An explicit request is authoritative and must be supported. A persisted
// spoon runtime (seeded to 'opencode' for every Spoon) is only honored when
// the profile can actually drive it; otherwise fall back to the profile's
// primary runtime so ChatGPT-login profiles still resolve to codex.
const settingsRuntime = settings.runtime as AgentRuntimeName | undefined;
const primaryRuntime = supported[0] ?? 'opencode';
const resolvedRuntime =
requestedRuntime ??
(settingsRuntime && supported.includes(settingsRuntime)
? settingsRuntime
: primaryRuntime);
if (!supported.includes(resolvedRuntime)) {
throw new ConvexError(
`Provider "${profile.name}" cannot run the "${resolvedRuntime}" runtime. Supported: ${supported.join(', ')}.`,
);
}
const shouldMaterializeEnvFile =
materializeEnvFile ?? settings.materializeEnvFileByDefault;
const envFilePath =
@@ -488,7 +509,7 @@ const insertJob = async (
jobType: requestedJobType,
status: 'queued',
prompt,
runtime: jobRuntime,
runtime: resolvedRuntime,
workspaceStatus: 'not_started',
baseBranch: resolvedBaseBranch,
workBranch,
@@ -596,6 +617,7 @@ export const createForThread = mutation({
jobType,
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
runtime: v.optional(runtime),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -649,6 +671,7 @@ export const createForThread = mutation({
requestedJobType: args.jobType,
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
@@ -664,6 +687,7 @@ export const createForThreadInternal = internalMutation({
jobType,
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
runtime: v.optional(runtime),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -719,6 +743,7 @@ export const createForThreadInternal = internalMutation({
requestedJobType: args.jobType,
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId,
@@ -1140,7 +1165,7 @@ export const claimNextInternal = internalMutation({
await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.');
return null;
}
if ((job.runtime ?? 'opencode') !== 'opencode') {
if (job.runtime === 'openai_direct') {
await failJobClaim(
ctx,
job,
@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getRequiredUserId, optionalText } from './model';
import { runtimesForProfile } from './runtimeSupport';
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
isDefault?: boolean;
@@ -59,6 +60,7 @@ const publicProfile = (
reasoningEffort: profile.reasoningEffort,
enabled: profile.enabled,
configured: isConfigured(profile),
supportedRuntimes: runtimesForProfile(profile),
isDefault: profile._id === defaultProfileId,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
+4
View File
@@ -245,6 +245,9 @@ export const createUserThread = mutation({
prompt: v.string(),
baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()),
runtime: v.optional(
v.union(v.literal('codex'), v.literal('opencode'), v.literal('claude')),
),
materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -284,6 +287,7 @@ export const createUserThread = mutation({
jobType: 'user_change',
baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName,
runtime: args.runtime,
materializeEnvFile: args.materializeEnvFile,
envFilePath: args.envFilePath,
aiProviderProfileId: args.aiProviderProfileId,