fix(agent-jobs): recover failed claim decryption

This commit is contained in:
Gabriel Brown
2026-07-10 17:26:17 -04:00
parent cf45404b7d
commit 1e1435e656
3 changed files with 238 additions and 66 deletions
+72 -35
View File
@@ -294,6 +294,40 @@ const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
await ctx.db.delete(job._id);
};
const failJobClaim = async (
ctx: MutationCtx,
job: Doc<'agentJobs'>,
reason: string,
) => {
const failedAt = Date.now();
await ctx.db.patch(job._id, {
status: 'failed',
error: reason,
completedAt: failedAt,
updatedAt: failedAt,
});
await ctx.db.patch(job.agentRequestId, {
status: 'failed',
updatedAt: failedAt,
});
if (job.threadId) {
await ctx.db.patch(job.threadId, {
status: 'failed',
updatedAt: failedAt,
resolvedAt: failedAt,
});
}
await ctx.db.insert('agentJobEvents', {
jobId: job._id,
spoonId: job.spoonId,
ownerId: job.ownerId,
level: 'error',
phase: 'queued',
message: `Job could not be claimed: ${reason}`,
createdAt: failedAt,
});
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db
.query('spoonAgentSettings')
@@ -1078,59 +1112,48 @@ export const claimNextInternal = internalMutation({
aiProviderProfile?.ownerId === job.ownerId && aiProviderProfile.enabled
? aiProviderProfile
: null;
const failClaim = async (reason: string) => {
const failedAt = Date.now();
await ctx.db.patch(job._id, {
status: 'failed',
error: reason,
completedAt: failedAt,
updatedAt: failedAt,
});
await ctx.db.patch(job.agentRequestId, {
status: 'failed',
updatedAt: failedAt,
});
if (job.threadId) {
await ctx.db.patch(job.threadId, {
status: 'failed',
updatedAt: failedAt,
resolvedAt: failedAt,
});
}
await ctx.db.insert('agentJobEvents', {
jobId: job._id,
spoonId: job.spoonId,
ownerId: job.ownerId,
level: 'error',
phase: 'queued',
message: `Job could not be claimed: ${reason}`,
createdAt: failedAt,
});
};
if (!spoon) {
await failClaim('The Spoon for this job no longer exists.');
await failJobClaim(
ctx,
job,
'The Spoon for this job no longer exists.',
);
return null;
}
if (!ownedProfile) {
await failClaim(
await failJobClaim(
ctx,
job,
'AI is not configured for this user. Add an AI provider in settings.',
);
return null;
}
if (ownedProfile.authType !== 'none' && !ownedProfile.encryptedSecret) {
await failClaim('Selected AI provider is missing credentials.');
await failJobClaim(
ctx,
job,
'Selected AI provider is missing credentials.',
);
return null;
}
if (agentSettings && !agentSettings.enabled) {
await failClaim('Agent jobs are disabled for this Spoon.');
await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.');
return null;
}
if ((job.runtime ?? 'opencode') !== 'opencode') {
await failClaim('Legacy OpenAI direct jobs are no longer supported.');
await failJobClaim(
ctx,
job,
'Legacy OpenAI direct jobs are no longer supported.',
);
return null;
}
if (secrets.length !== job.selectedSecretIds.length) {
await failClaim('Selected secrets must belong to this Spoon.');
await failJobClaim(
ctx,
job,
'Selected secrets must belong to this Spoon.',
);
return null;
}
const now = Date.now();
@@ -1164,6 +1187,20 @@ export const claimNextInternal = internalMutation({
},
});
export const failClaimInternal = internalMutation({
args: {
jobId: v.id('agentJobs'),
workerId: v.string(),
reason: v.string(),
},
handler: async (ctx, { jobId, workerId, reason }) => {
const job = await ctx.db.get(jobId);
if (job?.status !== 'claimed' || job.claimedBy !== workerId) return null;
await failJobClaim(ctx, job, reason);
return null;
},
});
export const updateStatus = mutation({
args: {
workerToken: v.string(),
+20 -7
View File
@@ -58,6 +58,24 @@ export const claimNextForWorker = action({
if (!claimed) return null;
if (!claimed.spoon || !claimed.aiProviderProfile) return null;
const profile = claimed.aiProviderProfile;
let profileSecret: string | undefined;
let secrets: { name: string; value: string }[];
try {
profileSecret = profile.encryptedSecret
? decryptSecret(profile.encryptedSecret)
: undefined;
secrets = claimed.secrets.map((secret: Doc<'spoonSecrets'>) => ({
name: secret.name,
value: decryptSecret(secret.encryptedValue),
}));
} catch {
await ctx.runMutation(internal.agentJobs.failClaimInternal, {
jobId: claimed.job._id,
workerId: args.workerId,
reason: 'Stored AI credentials or Spoon secrets could not be decrypted.',
});
return null;
}
return {
job: claimed.job,
spoon: claimed.spoon,
@@ -70,9 +88,7 @@ export const claimNextForWorker = action({
name: profile.name,
provider: profile.provider,
authType: profile.authType,
secret: profile.encryptedSecret
? decryptSecret(profile.encryptedSecret)
: undefined,
secret: profileSecret,
baseUrl: profile.baseUrl,
model: profile.defaultModel,
reasoningEffort: profile.reasoningEffort,
@@ -84,10 +100,7 @@ export const claimNextForWorker = action({
claimed.spoon.githubInstallationId ??
process.env.GITHUB_APP_INSTALLATION_ID,
},
secrets: claimed.secrets.map((secret: Doc<'spoonSecrets'>) => ({
name: secret.name,
value: decryptSecret(secret.encryptedValue),
})),
secrets,
};
},
});