fix(work-packages): propagate fatal auth/rate-limit errors from candidate fetch

The union candidate fetch wrapped both listWorkPackages() legs in an
unconditional .catch(() => ({ items: [] })), swallowing 401/403/429 and
OpenProjectAuthenticationError along with genuinely transient failures. An
expired token would silently degrade to "0 candidates", and the LLM would
report work packages as not found rather than surfacing the auth failure.

Replace the inline catches with a shared listCandidates() helper that uses
isFatalSearchError (already used by searchPipeline) to rethrow fatal errors
and only swallow non-fatal ones, preserving the property that a bad response
on one leg doesn't kill the other leg's candidates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-18 11:34:17 +02:00
1 parent 326db0659f
commit a28067f61e
2 files changed
+81 -14

No files matched your search

+25 -13
View File
@@ -18,7 +18,7 @@ import type {
WorkPackageSummary,
} from "../client/types.ts";
import { resolveClient, resolveProjectId } from "./helper.ts";
import { searchPipeline } from "../search/pipeline.ts";
import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts";
import type { FieldSpec, MatchMode } from "../search/rank.ts";
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
@@ -147,22 +147,34 @@ export async function searchWorkPackages(
assigneeId: options?.assigneeId,
};
// A non-fatal failure on one leg (e.g. a transient 500) must not kill the
// whole search: the other leg's candidates are still worth ranking. A
// fatal failure (401/403/429) must propagate rather than degrade into "0
// candidates" — an expired token must not be reported to the model as "no
// results found".
const listCandidates = async (
params: ListWorkPackagesParams
): Promise<{ items: WorkPackageSummary[] }> => {
try {
return await listWorkPackages(params, opClient);
} catch (error: unknown) {
if (isFatalSearchError(error)) {
throw error;
}
return { items: [] };
}
};
const fetchCandidates = async (): Promise<WorkPackageSummary[]> => {
const [precise, broad] = await Promise.all([
listWorkPackages(
{ ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES },
opClient
).catch(() => ({ items: [] as WorkPackageSummary[] })),
listCandidates({ ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }),
matchMode === "exact"
? Promise.resolve({ items: [] as WorkPackageSummary[] })
: listWorkPackages(
{
...sharedFilters,
pageSize: MAX_WORK_PACKAGE_CANDIDATES,
sortBy: '[["updatedAt","desc"]]',
},
opClient
).catch(() => ({ items: [] as WorkPackageSummary[] })),
: listCandidates({
...sharedFilters,
pageSize: MAX_WORK_PACKAGE_CANDIDATES,
sortBy: '[["updatedAt","desc"]]',
}),
]);
const byId = new Map<number, WorkPackageSummary>();