From 412e054b1e497590e34a850da15ae6451574ed30 Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Wed, 15 Apr 2026 16:49:43 +0530 Subject: [PATCH 1/5] 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 a03fa487acd8af9be734ebccfb1ca93c5e5fc03d Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Sat, 25 Apr 2026 23:04:04 +0530 Subject: [PATCH 2/5] 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 051adb39b99a98d5b26d477a006a12c4c1d9ed57 Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Sat, 25 Apr 2026 23:25:01 +0530 Subject: [PATCH 3/5] 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 21fd2ddd6adbeec92f81056795689acf6dc679be Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Mon, 27 Apr 2026 18:24:01 +0530 Subject: [PATCH 4/5] 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"); From 87f371cc25415206accb335880aaed573ae92104 Mon Sep 17 00:00:00 2001 From: Ramesh Nethi Date: Sat, 23 May 2026 21:00:48 +0530 Subject: [PATCH 5/5] fix(ticket-sync): gate cross-workspace fallback and ignore .kilo folder --- .prettierignore | 2 ++ apps/api/src/services/repo-service.ts | 24 +++++++++------ apps/api/src/services/ticket-sync-service.ts | 2 +- packages/shared/src/error-classifier.test.ts | 32 ++++++++++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/.prettierignore b/.prettierignore index 66e63bd4..ed1b790b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,5 @@ **/node_modules **/helm pnpm-lock.yaml +.kilo/ +**/.kilo/** diff --git a/apps/api/src/services/repo-service.ts b/apps/api/src/services/repo-service.ts index ccc6bbc2..1b878c11 100644 --- a/apps/api/src/services/repo-service.ts +++ b/apps/api/src/services/repo-service.ts @@ -124,6 +124,7 @@ async function getDefaultWorkspaceId(): Promise { export async function getRepoByUrl( repoUrl: string, workspaceId?: string | null, + options?: { allowAnyWorkspace?: boolean }, ): Promise { const normalized = normalizeRepoUrl(repoUrl); const conditions = [eq(repos.repoUrl, normalized)]; @@ -132,7 +133,7 @@ export async function getRepoByUrl( } 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 workspace (for background jobs like ticket sync) + // then fall back to any workspace (for background jobs like ticket sync) if explicitly allowed const defaultWsId = await getDefaultWorkspaceId(); if (defaultWsId) { const [repo] = await db @@ -147,16 +148,19 @@ export async function getRepoByUrl( .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 anyRepos = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); - if (anyRepos.length > 0) { - if (anyRepos.length > 1) { - logger.warn( - { repoUrl: normalized, count: anyRepos.length }, - "Multiple repos found with same URL across workspaces - returning first match", - ); + + // Final fallback: any workspace (only if explicitly allowed, e.g. background jobs like ticket sync) + if (options?.allowAnyWorkspace) { + const anyRepos = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); + if (anyRepos.length > 0) { + 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 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 b6a5bfd1..7fe61981 100644 --- a/apps/api/src/services/ticket-sync-service.ts +++ b/apps/api/src/services/ticket-sync-service.ts @@ -144,7 +144,7 @@ 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); + const repoConfig = await getRepoByUrl(repoUrl, null, { allowAnyWorkspace: true }); if (!repoConfig) { logger.warn( { repoUrl, ticketId: ticket.externalId }, diff --git a/packages/shared/src/error-classifier.test.ts b/packages/shared/src/error-classifier.test.ts index dfc847f2..8b084561 100644 --- a/packages/shared/src/error-classifier.test.ts +++ b/packages/shared/src/error-classifier.test.ts @@ -157,4 +157,36 @@ describe("classifyError", () => { expect(result.category).toBe("auth"); expect(result.title).toBe("Authentication token expired"); }); + + describe("authentication error retryability", () => { + it("pins retryable: false for ANTHROPIC_API_KEY missing", () => { + const result = classifyError("Secret not found: ANTHROPIC_API_KEY"); + expect(result.category).toBe("auth"); + expect(result.retryable).toBe(false); + }); + + it("pins retryable: false for OPENAI_API_KEY missing", () => { + const result = classifyError("Secret not found: OPENAI_API_KEY"); + expect(result.category).toBe("auth"); + expect(result.retryable).toBe(false); + }); + + it("pins retryable: false for GEMINI_API_KEY missing", () => { + const result = classifyError("Secret not found: GEMINI_API_KEY"); + expect(result.category).toBe("auth"); + expect(result.retryable).toBe(false); + }); + + it("pins retryable: false for COPILOT_GITHUB_TOKEN missing", () => { + const result = classifyError("Secret not found: COPILOT_GITHUB_TOKEN"); + expect(result.category).toBe("auth"); + expect(result.retryable).toBe(false); + }); + + it("pins retryable: false for expired OAuth token", () => { + const result = classifyError("OAuth token has expired during task execution"); + expect(result.category).toBe("auth"); + expect(result.retryable).toBe(false); + }); + }); });