From ecc92b73eacd928a4ef173b351a942eca8fb4fc9 Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Wed, 15 Apr 2026 16:49:43 +0530 Subject: [PATCH 1/4] fix: fall back across workspaces in getRepoByUrl for background jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a workspaceId, getRepoByUrl only looked in the 'default' slug workspace. Background jobs like ticket sync have no workspace context, so repos in user-created workspaces were never found — causing tasks to fall back to claude-code regardless of the configured default agent type. Added a three-tier fallback: default workspace → NULL workspace_id → any workspace. Also surfaces secret retrieval failures in ticket sync as warnings instead of swallowing them silently. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/repo-service.ts | 14 ++++++++++++-- apps/api/src/services/ticket-sync-service.ts | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/repo-service.ts b/apps/api/src/services/repo-service.ts index be0968fe..559bc6eb 100644 --- a/apps/api/src/services/repo-service.ts +++ b/apps/api/src/services/repo-service.ts @@ -130,7 +130,8 @@ export async function getRepoByUrl( conditions.push(eq(repos.workspaceId, workspaceId)); } else { // When no workspace is specified, try the default workspace first, - // then fall back to any repo with a NULL workspace_id + // then fall back to any repo with a NULL workspace_id, + // then fall back to any workspace (for background jobs like ticket sync) const defaultWsId = await getDefaultWorkspaceId(); if (defaultWsId) { const [repo] = await db @@ -139,7 +140,16 @@ export async function getRepoByUrl( .where(and(eq(repos.repoUrl, normalized), eq(repos.workspaceId, defaultWsId))); if (repo) return decryptRepoRow(repo); } - conditions.push(isNull(repos.workspaceId)); + // Try NULL workspace_id + const [nullWsRepo] = await db + .select() + .from(repos) + .where(and(eq(repos.repoUrl, normalized), isNull(repos.workspaceId))); + if (nullWsRepo) return decryptRepoRow(nullWsRepo); + // Final fallback: any workspace (background jobs like ticket sync have no workspace context) + const [anyRepo] = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); + if (anyRepo) return decryptRepoRow(anyRepo); + return null; } const [repo] = await db .select() diff --git a/apps/api/src/services/ticket-sync-service.ts b/apps/api/src/services/ticket-sync-service.ts index 524b76dd..5a6b16e4 100644 --- a/apps/api/src/services/ticket-sync-service.ts +++ b/apps/api/src/services/ticket-sync-service.ts @@ -37,8 +37,11 @@ export async function syncAllTickets(): Promise { ); const credentials = JSON.parse(secretJson); mergedConfig = { ...mergedConfig, ...credentials }; - } catch { - // No secrets stored for this provider — use config as-is + } catch (secretErr) { + logger.warn( + { err: secretErr, source: providerConfig.source, id: providerConfig.id }, + "[ticket-sync] Failed to retrieve secret for provider — using config as-is", + ); } // GitHub fallback: if no token was supplied via config or provider secret, From f631ffb1afde9587ec51ce86d0980fc90fbcc552 Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Sat, 25 Apr 2026 23:04:04 +0530 Subject: [PATCH 2/4] fix(tests): replace orderBy with in-memory sort to fix test mocks --- apps/api/src/services/repo-service.ts | 10 +++++++--- apps/api/src/services/ticket-sync-service.ts | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/repo-service.ts b/apps/api/src/services/repo-service.ts index 559bc6eb..7dc80ac2 100644 --- a/apps/api/src/services/repo-service.ts +++ b/apps/api/src/services/repo-service.ts @@ -1,4 +1,4 @@ -import { eq, and, isNull } from "drizzle-orm"; +import { eq, and, isNull, desc } from "drizzle-orm"; import { db } from "../db/client.js"; import { repos, workspaces } from "../db/schema.js"; import { encrypt, decrypt, ALG_AES_256_GCM_V1 } from "./secret-service.js"; @@ -147,8 +147,12 @@ export async function getRepoByUrl( .where(and(eq(repos.repoUrl, normalized), isNull(repos.workspaceId))); if (nullWsRepo) return decryptRepoRow(nullWsRepo); // Final fallback: any workspace (background jobs like ticket sync have no workspace context) - const [anyRepo] = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); - if (anyRepo) return decryptRepoRow(anyRepo); + // Order by createdAt desc to prefer the most recently configured repo when multiple workspaces exist + const anyRepos = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); + if (anyRepos.length > 0) { + anyRepos.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return decryptRepoRow(anyRepos[0]); + } return null; } const [repo] = await db diff --git a/apps/api/src/services/ticket-sync-service.ts b/apps/api/src/services/ticket-sync-service.ts index 5a6b16e4..f6297a76 100644 --- a/apps/api/src/services/ticket-sync-service.ts +++ b/apps/api/src/services/ticket-sync-service.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, desc } from "drizzle-orm"; import { db } from "../db/client.js"; import { ticketProviders, repos } from "../db/schema.js"; import { getTicketProvider } from "@optio/ticket-providers"; @@ -22,7 +22,9 @@ export async function syncAllTickets(): Promise { .where(eq(ticketProviders.enabled, true)); // Fetch configured repos once before the provider loop (avoids redundant queries) - const configuredRepos = await db.select({ repoUrl: repos.repoUrl }).from(repos); + // Sort by createdAt desc so that if multiple workspaces have the same repo, we prefer the latest setup + const configuredRepos = await db.select().from(repos); + configuredRepos.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); let totalSynced = 0; From 1a167e6cecda667e285fb879447700ae20b5be1e Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Sat, 25 Apr 2026 23:25:01 +0530 Subject: [PATCH 3/4] fix(shared): categorize missing api keys as unrecoverable errors to prevent reconcile loops --- packages/shared/src/error-classifier.ts | 39 ++++++++++++++++--------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/error-classifier.ts b/packages/shared/src/error-classifier.ts index 4a8041e8..9b03bc8a 100644 --- a/packages/shared/src/error-classifier.ts +++ b/packages/shared/src/error-classifier.ts @@ -48,14 +48,17 @@ const ERROR_PATTERNS: Array<{ }), }, { - pattern: /Secret not found: (\w+)/i, - classify: (match) => ({ - category: "auth", - title: `Missing secret: ${match[1]}`, - description: `The required secret "${match[1]}" is not configured. The agent needs this credential to run.`, - remedy: `Go to Secrets and add "${match[1]}", or re-run the setup wizard.`, - retryable: true, - }), + pattern: /Secret not found: (\w+)|no (\w+) secret found/i, + classify: (match) => { + const missingSecret = match[1] || match[2]; + return { + category: "auth", + title: `Missing secret: ${missingSecret}`, + description: `The required secret "${missingSecret}" is not configured. The agent needs this credential to run.`, + remedy: `Go to Secrets and add "${missingSecret}", or re-run the setup wizard.`, + retryable: false, // Don't retry missing secrets - requires user action + }; + }, }, { pattern: @@ -72,7 +75,7 @@ const ERROR_PATTERNS: Array<{ " security find-generic-password -s \"Claude Code-credentials\" -w | python3 -c \"import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['accessToken'])\" | pbcopy\n\n" + "Or re-run 'claude setup-token' to go through the setup flow again.\n" + "Retry the failed tasks after updating the token.", - retryable: true, + retryable: false, }), }, { @@ -83,7 +86,7 @@ const ERROR_PATTERNS: Array<{ description: "No Anthropic API key is configured and Claude Code cannot authenticate.", remedy: "Go to Secrets and add ANTHROPIC_API_KEY, or switch to Max subscription auth in Settings.", - retryable: true, + retryable: false, }), }, { @@ -94,7 +97,7 @@ const ERROR_PATTERNS: Array<{ description: "No OpenAI API key is configured and the Codex agent cannot authenticate with the OpenAI API.", remedy: "Go to Secrets and add OPENAI_API_KEY with a valid OpenAI API key.", - retryable: true, + retryable: false, }), }, { @@ -105,7 +108,17 @@ const ERROR_PATTERNS: Array<{ description: "No OpenClaw API key is configured and the OpenClaw agent cannot authenticate.", remedy: "Go to Secrets and add OPENCLAW_API_KEY, or provide an ANTHROPIC_API_KEY or OPENAI_API_KEY instead.", - retryable: true, + retryable: false, + }), + }, + { + pattern: /GEMINI_API_KEY/i, + classify: () => ({ + category: "auth", + title: "Gemini API key missing", + description: "No Gemini API key is configured and Gemini cannot authenticate.", + remedy: "Go to Secrets and add GEMINI_API_KEY with a valid Gemini API key.", + retryable: false, }), }, { @@ -117,7 +130,7 @@ const ERROR_PATTERNS: Array<{ "No valid Copilot token is configured. The Copilot agent requires a GitHub token with Copilot Requests permission and an active Copilot subscription.", remedy: "Go to Secrets and add COPILOT_GITHUB_TOKEN with a fine-grained PAT that has the Copilot Requests permission. Classic PATs (ghp_) are not supported.", - retryable: true, + retryable: false, }), }, { From 8ce4d0354b1e26b82c34c67dbfece4705af6cf3c Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Mon, 27 Apr 2026 18:24:01 +0530 Subject: [PATCH 4/4] fix(ticket-sync): remove in-memory sorts and add workspace awareness - Remove in-memory sort by createdAt in repo-service and ticket-sync - Add warning when multiple repos found across workspaces - Add warning when repo config not found in ticket-sync - Pass workspaceId to createTask to ensure correct workspace assignment - Remove unused desc imports This ensures background jobs like ticket-sync work correctly with multi-workspace setups without breaking test cases. --- apps/api/src/services/repo-service.ts | 11 ++++++++--- apps/api/src/services/ticket-sync-service.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/repo-service.ts b/apps/api/src/services/repo-service.ts index 7dc80ac2..ccc6bbc2 100644 --- a/apps/api/src/services/repo-service.ts +++ b/apps/api/src/services/repo-service.ts @@ -1,8 +1,9 @@ -import { eq, and, isNull, desc } from "drizzle-orm"; +import { eq, and, isNull } from "drizzle-orm"; import { db } from "../db/client.js"; import { repos, workspaces } from "../db/schema.js"; import { encrypt, decrypt, ALG_AES_256_GCM_V1 } from "./secret-service.js"; import { normalizeRepoUrl, parseRepoUrl } from "@optio/shared"; +import { logger } from "../logger.js"; export interface RepoRecord { id: string; @@ -147,10 +148,14 @@ export async function getRepoByUrl( .where(and(eq(repos.repoUrl, normalized), isNull(repos.workspaceId))); if (nullWsRepo) return decryptRepoRow(nullWsRepo); // Final fallback: any workspace (background jobs like ticket sync have no workspace context) - // Order by createdAt desc to prefer the most recently configured repo when multiple workspaces exist const anyRepos = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); if (anyRepos.length > 0) { - anyRepos.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + if (anyRepos.length > 1) { + logger.warn( + { repoUrl: normalized, count: anyRepos.length }, + "Multiple repos found with same URL across workspaces - returning first match", + ); + } return decryptRepoRow(anyRepos[0]); } return null; diff --git a/apps/api/src/services/ticket-sync-service.ts b/apps/api/src/services/ticket-sync-service.ts index f6297a76..b6a5bfd1 100644 --- a/apps/api/src/services/ticket-sync-service.ts +++ b/apps/api/src/services/ticket-sync-service.ts @@ -1,4 +1,4 @@ -import { eq, desc } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; import { ticketProviders, repos } from "../db/schema.js"; import { getTicketProvider } from "@optio/ticket-providers"; @@ -22,9 +22,7 @@ export async function syncAllTickets(): Promise { .where(eq(ticketProviders.enabled, true)); // Fetch configured repos once before the provider loop (avoids redundant queries) - // Sort by createdAt desc so that if multiple workspaces have the same repo, we prefer the latest setup const configuredRepos = await db.select().from(repos); - configuredRepos.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); let totalSynced = 0; @@ -147,6 +145,12 @@ export async function syncAllTickets(): Promise { // Resolve agent type: ticket label > repo default > "claude-code" const { getRepoByUrl } = await import("./repo-service.js"); const repoConfig = await getRepoByUrl(repoUrl); + if (!repoConfig) { + logger.warn( + { repoUrl, ticketId: ticket.externalId }, + "[ticket-sync] Repository configuration not found. Task will be created without workspace context.", + ); + } const labelAgent = ticket.labels.includes("codex") ? "codex" : ticket.labels.includes("copilot") @@ -162,6 +166,7 @@ export async function syncAllTickets(): Promise { ticketSource: ticket.source, ticketExternalId: ticket.externalId, metadata: { ticketUrl: ticket.url }, + workspaceId: repoConfig?.workspaceId, }); await taskService.transitionTask(task.id, TaskState.QUEUED, "ticket_sync");