From 013c597b4e03355112e3d94540b1542af3be143f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:10:43 +0200 Subject: [PATCH 01/10] feat: Record Lane Git State and Serve Conversation Pull Requests A conversation now stores the branch, head and repository its code lane last reported, written only by the server after a finished bash command and excluded from ordinary reads, generic saves, unset fields and imports. GET /api/convos/:conversationId/pull-request finds the pull request for that branch through an injected GitHub source with a bounded cache, answers null for an absence, and maps failures to stable codes without upstream text. The feature is off unless endpoints.agents.pullRequests is enabled with a token reference. --- .../__test-utils__/convos-route-mocks.js | 5 + api/server/routes/__tests__/convos.spec.js | 15 ++ api/server/routes/convos.js | 10 + api/server/services/ToolService.js | 9 +- .../services/__tests__/ToolService.spec.js | 55 +++++ librechat.example.yaml | 8 + packages/api/src/code/command.ts | 11 +- packages/api/src/code/index.ts | 1 + packages/api/src/code/lane.spec.ts | 134 +++++++++++ packages/api/src/code/lane.ts | 54 +++++ packages/api/src/index.ts | 1 + packages/api/src/pulls/github.spec.ts | 177 ++++++++++++++ packages/api/src/pulls/github.ts | 226 ++++++++++++++++++ packages/api/src/pulls/handler.spec.ts | 157 ++++++++++++ packages/api/src/pulls/handler.ts | 95 ++++++++ packages/api/src/pulls/index.ts | 4 + packages/api/src/pulls/lookup.spec.ts | 105 ++++++++ packages/api/src/pulls/lookup.ts | 64 +++++ packages/api/src/pulls/types.ts | 37 +++ .../src/methods/conversation.spec.ts | 120 ++++++++++ .../data-schemas/src/methods/conversation.ts | 64 +++++ packages/data-schemas/src/schema/convo.ts | 12 + packages/data-schemas/src/types/convo.ts | 10 + 23 files changed, 1372 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/code/lane.spec.ts create mode 100644 packages/api/src/code/lane.ts create mode 100644 packages/api/src/pulls/github.spec.ts create mode 100644 packages/api/src/pulls/github.ts create mode 100644 packages/api/src/pulls/handler.spec.ts create mode 100644 packages/api/src/pulls/handler.ts create mode 100644 packages/api/src/pulls/index.ts create mode 100644 packages/api/src/pulls/lookup.spec.ts create mode 100644 packages/api/src/pulls/lookup.ts create mode 100644 packages/api/src/pulls/types.ts diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index bd86738d139..ce6a630d933 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -147,6 +147,11 @@ module.exports = { () => (_req, res) => res.status(200).json({ tasks: [] }), ), createBackgroundTaskPolicyMiddleware: jest.fn(() => (_req, _res, next) => next()), + createGitHubPullRequestSource: jest.fn(() => ({ find: jest.fn() })), + createPullRequestLookup: jest.fn(() => jest.fn()), + createConversationPullRequestHandler: jest.fn( + () => (_req, res) => res.status(200).json({ pullRequest: null }), + ), createBackgroundTaskCancelHandler: jest.fn( () => (_req, res) => res.status(200).json({ results: [] }), ), diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 1cc49c7430f..0c448a67abc 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -237,6 +237,21 @@ describe('Convos Routes', () => { }); }); + describe('GET /:conversationId/pull-request', () => { + it('is registered and reaches the pull request handler', async () => { + const response = await request(app).get('/api/convos/ordinary/pull-request'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ pullRequest: null }); + }); + + it('does not fall through to the conversation read', async () => { + await request(app).get('/api/convos/ordinary/pull-request'); + + expect(getConvo).not.toHaveBeenCalled(); + }); + }); + describe('POST /import', () => { const { importConversations } = require('~/server/utils/import'); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index ebb02973b90..6a1dea1f03e 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -19,6 +19,9 @@ const { createBackgroundTaskIndexHandler, createBackgroundTaskCancelHandler, createBackgroundTaskPolicyMiddleware, + createConversationPullRequestHandler, + createGitHubPullRequestSource, + createPullRequestLookup, backgroundTaskRegistry, createSubagentThreadViewHandler, createGeneratedTitleHandler, @@ -167,6 +170,12 @@ const backgroundTaskIndexHandler = createBackgroundTaskIndexHandler({ const backgroundTaskCancelHandler = createBackgroundTaskCancelHandler({ registry: backgroundTaskRegistry, }); +const conversationPullRequestHandler = createConversationPullRequestHandler({ + getConvoLaneGit: db.getConvoLaneGit, + getAppConfig, + lookup: createPullRequestLookup({ source: createGitHubPullRequestSource() }), + env: process.env, +}); router.use(requireJwtAuth); const isValidProjectFilter = (projectId) => @@ -239,6 +248,7 @@ router.post( subagentControlHandler, ); router.get('/:parentConversationId/subagents', parentSubagentIndexHandler); +router.get('/:conversationId/pull-request', conversationPullRequestHandler); router.get('/:conversationId/background-tasks', backgroundTaskPolicy, backgroundTaskIndexHandler); router.post( '/:conversationId/background-tasks/cancel', diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 77af238288e..2ca1500f82d 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -50,6 +50,7 @@ const { AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE, isFatalAgentInitializationError, codeExecutionAuthHeaders, + createLaneGitRecorder, createAttachedWorkspaceBashTool, createRepositoryInstructionSource, createRepositoryInstructionLoader, @@ -130,7 +131,7 @@ const { createOpenIDSessionTokenProvider } = require('~/server/services/OpenIDSe const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); -const { findPluginAuthsByKeys, getRoleByName } = require('~/models'); +const { findPluginAuthsByKeys, getRoleByName, setConvoLaneGit } = require('~/models'); const { getFlowStateManager, getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); @@ -2386,6 +2387,12 @@ async function loadToolsForExecution({ baseUrl: codeExecutionContext.baseUrl, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId, + onLaneGit: createLaneGitRecorder({ + user: req.user.id, + conversationId: conversationId ?? runtimeRequestBody?.conversationId, + repo: codeExecutionContext.codeWorkspace.environment?.repo, + setConvoLaneGit, + }), linkedWorktrees: codeExecutionContext.codeWorkspace.linkedWorktrees, nativeSandbox: codeExecutionContext.codeWorkspace.nativeSandbox, environment: codeExecutionContext.codeWorkspace.environment, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index df7cbed6be6..4f986aeac73 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -22,6 +22,9 @@ const mockGetCachedTools = jest.fn(); const mockSendEvent = jest.fn(); const mockEmitChunk = jest.fn(); const mockCreateAttachedWorkspaceBashTool = jest.fn(() => ({ name: AgentConstants.BASH_TOOL })); +const mockLaneGitRecorder = jest.fn(); +const mockCreateLaneGitRecorder = jest.fn(() => mockLaneGitRecorder); +const mockSetConvoLaneGit = jest.fn(); const attachedWorkspaceOperations = [ 'read_file', 'search_text', @@ -121,6 +124,7 @@ jest.mock('@librechat/api', () => ({ resolveCodeExecutionWorkspaceContext: (...args) => mockResolveCodeExecutionWorkspaceContext(...args), createAttachedWorkspaceBashTool: (...args) => mockCreateAttachedWorkspaceBashTool(...args), + createLaneGitRecorder: (...args) => mockCreateLaneGitRecorder(...args), })); const mockLoadToolsUtil = jest.fn(); @@ -171,6 +175,7 @@ const mockGetRoleByName = jest.fn(); jest.mock('~/models', () => ({ findPluginAuthsByKeys: jest.fn(), getRoleByName: (...args) => mockGetRoleByName(...args), + setConvoLaneGit: (...args) => mockSetConvoLaneGit(...args), })); jest.mock('~/config', () => ({ getFlowStateManager: jest.fn(() => mockFlowManager), @@ -3221,6 +3226,7 @@ describe('ToolService - Action Capability Gating', () => { authHeaders: expect.any(Function), baseUrl: 'http://attached-code.test/v1', workspaceId: 'project-a', + onLaneGit: mockLaneGitRecorder, gitIdentity: { name: 'LibreChat Agent', email: 'agent@example.com' }, maxTimeoutMs: 65_000, defaultTimeoutMs: 60_000, @@ -3243,6 +3249,55 @@ describe('ToolService - Action Capability Gating', () => { expect(result.loadedTools).toContainEqual({ name: AgentConstants.BASH_TOOL }); }); + it('records the lane for the requesting user and the resolved conversation', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.execute_code, + AgentCapabilities.stateful_code_sessions, + ]; + const req = createMockReq(capabilities); + req.body = { + conversationId: 'body-convo', + codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }], + }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockResolveCodeExecutionContext.mockReturnValueOnce({ + baseUrl: 'http://attached-code.test/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + statefulSessions: true, + environmentType: 'attached', + environmentId: 'personal-machine', + bridgeWorkerId: 'worker-abc', + }); + mockCreateLaneGitRecorder.mockClear(); + + await loadToolsForExecution({ + req, + res: {}, + agent: { + id: 'attached-agent', + tools: [Tools.execute_code], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + }, + conversationId: 'resolved-convo', + toolNames: [AgentConstants.BASH_TOOL], + toolRegistry: new Map([[AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }]]), + actionsEnabled: false, + }); + + expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith({ + user: req.user.id, + conversationId: 'resolved-convo', + setConvoLaneGit: expect.any(Function), + }); + const { setConvoLaneGit } = mockCreateLaneGitRecorder.mock.calls[0][0]; + const input = { user: 'u', conversationId: 'c', laneGit: { branch: 'main', head: null } }; + await setConvoLaneGit(input); + expect(mockSetConvoLaneGit).toHaveBeenCalledWith(input); + }); + it('passes negotiated lane and native sandbox capabilities to the attached bash tool', async () => { const capabilities = [ AgentCapabilities.tools, diff --git a/librechat.example.yaml b/librechat.example.yaml index a40971434ad..9cb3d6456a2 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -922,6 +922,14 @@ endpoints: # completionReceiptBatching: true # # Allow owners to cooperatively cancel ordinary background tools, including BYOM Bash. # ordinaryToolCancellation: false + # # (optional) Show a conversation's pull request in the chat header. Off by default. It needs + # # a code worker that reports its branch, and a read-only GitHub token held on the server. + # pullRequests: + # enabled: false + # # Environment variable reference only, never the token itself. + # token: "${GITHUB_PULL_REQUEST_TOKEN}" + # # Seconds a looked-up pull request is reused before GitHub is asked again (5 to 3600). + # cacheTtlSeconds: 30 # # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in. # # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools. # # (optional) Require user approval before matching tool calls. Disabled by default. diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts index 88154a05d5e..593f96d2949 100644 --- a/packages/api/src/code/command.ts +++ b/packages/api/src/code/command.ts @@ -14,7 +14,11 @@ import type { import type { createBashProgrammaticToolCallingSchema } from '@librechat/agents'; import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools'; import type { LCTool } from '@librechat/agents'; -import type { WorkspaceAdmissionOptions, WorkspaceExecuteCommandResult } from './workspace'; +import type { + WorkspaceAdmissionOptions, + WorkspaceExecuteCommandResult, + WorkspaceLaneGit, +} from './workspace'; import type { CodeExecutionContext } from '~/agents/execution'; import type { CodeBridgeFetch } from './bridge'; import { @@ -468,11 +472,15 @@ export function createAttachedWorkspaceBashTool({ minCommandAdmissionMs, linkedWorktrees = false, nativeSandbox = false, + onLaneGit, fetchImpl, }: { baseUrl: string; authHeaders: () => Promise> | Record; workspaceId: string; + /** Receives the lane's branch and head when a finished command reports them. It is not awaited + * and must not throw: a failure here never changes the command's result. */ + onLaneGit?: (laneGit: WorkspaceLaneGit) => void; workspaceInstanceId?: string; /** The worker runs each `.worktrees/` in its own lane; a matching `cwd` is routed there. */ linkedWorktrees?: boolean; @@ -609,6 +617,7 @@ export function createAttachedWorkspaceBashTool({ throw new Error('Attached workspace returned an unexpected command result.'); } logger.debug('[BYOMCommand] transport completed', trace); + if (result.laneGit != null) onLaneGit?.(result.laneGit); let content = formatCommandResult( result, selectedTimeoutMs, diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 237e3a04561..b6adfe68253 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -5,6 +5,7 @@ export * from './bridge'; export * from './lifecycle'; export * from './workspace'; export * from './command'; +export * from './lane'; export * from './capabilities'; export * from './decision'; export * from './inheritance'; diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts new file mode 100644 index 00000000000..a278793de64 --- /dev/null +++ b/packages/api/src/code/lane.spec.ts @@ -0,0 +1,134 @@ +import { logger } from '@librechat/data-schemas'; +import type { CodeBridgeFetch } from './bridge'; +import { createAttachedWorkspaceBashTool } from './command'; +import { createLaneGitRecorder } from './lane'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn() }, +})); + +const head = 'a'.repeat(40); +const laneGit = { branch: 'feat/pr-chip', head }; + +describe('createLaneGitRecorder', () => { + it('writes the reported state for the owner and conversation', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ user: 'u1', conversationId: 'c1', setConvoLaneGit }); + await expect(record?.(laneGit)).resolves.toBe(true); + expect(setConvoLaneGit).toHaveBeenCalledWith({ user: 'u1', conversationId: 'c1', laneGit }); + }); + + it.each([ + ['a missing user', { user: undefined, conversationId: 'c1' }], + ['an empty user', { user: '', conversationId: 'c1' }], + ['a missing conversation', { user: 'u1', conversationId: undefined }], + ['an empty conversation', { user: 'u1', conversationId: '' }], + ])('does not record with %s', (_label, ids) => { + const setConvoLaneGit = jest.fn(); + expect(createLaneGitRecorder({ ...ids, setConvoLaneGit })).toBeUndefined(); + expect(setConvoLaneGit).not.toHaveBeenCalled(); + }); + + it('stores a plain owner/name repository with the lane', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'c1', + repo: 'LibreChat-AI/LibreChat', + setConvoLaneGit, + }); + await record?.(laneGit); + expect(setConvoLaneGit).toHaveBeenCalledWith({ + user: 'u1', + conversationId: 'c1', + laneGit, + repo: 'LibreChat-AI/LibreChat', + }); + }); + + it.each(['../x', 'o/..', 'a b/c', 'owner', 'o/r/extra', 'x'.repeat(300)])( + 'drops the unsafe repository %s but still records the lane', + async (repo) => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'c1', + repo, + setConvoLaneGit, + }); + await record?.(laneGit); + expect(setConvoLaneGit).toHaveBeenCalledWith({ + user: 'u1', + conversationId: 'c1', + laneGit, + }); + }, + ); + + it('swallows a database failure and logs only safe metadata', async () => { + const setConvoLaneGit = jest.fn().mockRejectedValue(new Error('mongodb://user:secret@host')); + const record = createLaneGitRecorder({ user: 'u1', conversationId: 'c1', setConvoLaneGit }); + await expect(record?.(laneGit)).resolves.toBe(false); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(JSON.stringify((logger.warn as jest.Mock).mock.calls)).not.toContain('secret'); + }); +}); + +describe('attached bash tool lane reporting', () => { + const response = (extra: Record = {}) => + Response.json({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'project-a', + exitCode: 0, + stdout: 'ok', + stderr: '', + truncated: false, + timedOut: false, + ...extra, + }); + const build = (fetchImpl: CodeBridgeFetch, onLaneGit?: jest.Mock) => + createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + workspaceId: 'project-a', + onLaneGit, + fetchImpl, + }); + + it('hands the reported branch and head to the recorder', async () => { + const onLaneGit = jest.fn(); + await build( + jest.fn(async () => response({ laneGit })), + onLaneGit, + ).invoke({ command: 'git status' }); + expect(onLaneGit).toHaveBeenCalledWith(laneGit); + }); + + it('hands over a detached lane as nulls, not as unknown', async () => { + const onLaneGit = jest.fn(); + const detached = { branch: null, head: null }; + await build( + jest.fn(async () => response({ laneGit: detached })), + onLaneGit, + ).invoke({ command: 'git status' }); + expect(onLaneGit).toHaveBeenCalledWith(detached); + }); + + it('does not call the recorder when the worker sent no laneGit', async () => { + const onLaneGit = jest.fn(); + await build( + jest.fn(async () => response()), + onLaneGit, + ).invoke({ command: 'ls' }); + expect(onLaneGit).not.toHaveBeenCalled(); + }); + + it('still returns the command output when no recorder is configured', async () => { + const output = await build(jest.fn(async () => response({ laneGit }))).invoke({ + command: 'ls', + }); + expect(String(output)).toContain('ok'); + }); +}); diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts new file mode 100644 index 00000000000..d44315c43ec --- /dev/null +++ b/packages/api/src/code/lane.ts @@ -0,0 +1,54 @@ +import { logger } from '@librechat/data-schemas'; +import type { WorkspaceLaneGit } from './workspace'; +import { getSafeErrorMetadata } from '~/utils'; + +export type LaneGitWriter = (input: { + user: string; + conversationId: string; + laneGit: WorkspaceLaneGit; + repo?: string; +}) => Promise; + +const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const MAX_REPO_LENGTH = 256; + +const isSafeRepo = (repo: string | undefined): repo is string => + repo != null && + repo.length <= MAX_REPO_LENGTH && + REPO_PATTERN.test(repo) && + repo.split('/').every((segment) => segment !== '.' && segment !== '..'); + +/** + * Records the lane state a finished command reported, for the owner's conversation. It never + * throws and is never awaited by the command: the branch is a header affordance, so a database + * outage must not fail or delay the agent's tool call. Resolves to whether the stored value + * changed, which is false for an unchanged lane and for a conversation not saved yet. A repo that + * is not a plain `owner/name` is dropped rather than stored. + */ +export function createLaneGitRecorder({ + user, + conversationId, + repo, + setConvoLaneGit, +}: { + user: string | undefined; + conversationId: string | undefined; + repo?: string; + setConvoLaneGit: LaneGitWriter; +}): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { + if (!user || !conversationId) return undefined; + const safeRepo = isSafeRepo(repo) ? repo : undefined; + return async (laneGit) => { + try { + return await setConvoLaneGit({ + user, + conversationId, + laneGit, + ...(safeRepo ? { repo: safeRepo } : {}), + }); + } catch (error) { + logger.warn('[LaneGit] Failed to record lane state', getSafeErrorMetadata(error)); + return false; + } + }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b5d05ebf3fa..54ba5cd1852 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -11,6 +11,7 @@ export * from './artifacts'; export * from './admin'; export * from './cdn'; export * from './code'; +export * from './pulls'; /* Auth */ export * from './auth'; /* API Keys */ diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts new file mode 100644 index 00000000000..4c9031ff289 --- /dev/null +++ b/packages/api/src/pulls/github.spec.ts @@ -0,0 +1,177 @@ +import { createGitHubPullRequestSource, summarizeChecks } from './github'; +import { PullRequestSourceError } from './types'; + +const sha = 'a'.repeat(40); +const pull = (overrides: Record = {}) => ({ + number: 7, + title: 'Simplify single tool', + html_url: 'https://github.com/o/r/pull/7', + state: 'open', + merged: false, + draft: false, + additions: 12, + deletions: 3, + mergeable: true, + ...overrides, +}); +const listed = (state = 'open') => [{ number: 7, state, head: { sha } }]; +const json = (body: unknown, init?: ResponseInit) => Response.json(body, init); + +function sourceFor(routes: Record Response>) { + const fetchFn = jest.fn(async (input: string) => { + const url = String(input); + const key = Object.keys(routes).find((fragment) => url.includes(fragment)); + if (key == null) throw new Error(`unexpected ${url}`); + return routes[key](); + }); + return { fetchFn, source: createGitHubPullRequestSource({ fetchFn }) }; +} + +const find = (source: ReturnType) => + source.find({ repo: 'o/r', branch: 'feat/x', token: 't' }); + +describe('summarizeChecks', () => { + it.each([ + ['none', []], + ['passing', [{ status: 'completed', conclusion: 'success' }]], + ['passing', [{ status: 'completed', conclusion: 'skipped' }]], + ['running', [{ status: 'in_progress', conclusion: null }]], + ['failing', [{ status: 'completed', conclusion: 'failure' }]], + [ + 'failing', + [ + { status: 'in_progress', conclusion: null }, + { status: 'completed', conclusion: 'timed_out' }, + ], + ], + ])('reports %s', (expected, runs) => { + expect(summarizeChecks(runs)).toBe(expected); + }); +}); + +describe('createGitHubPullRequestSource', () => { + const routes = (pullBody = pull(), checks: unknown = { check_runs: [] }) => ({ + '/pulls?state=all': () => json(listed()), + '/pulls/7': () => json(pullBody), + '/check-runs': () => json(checks), + }); + + it('maps an open, mergeable pull request with passing checks', async () => { + const { source } = sourceFor( + routes(pull(), { check_runs: [{ status: 'completed', conclusion: 'success' }] }), + ); + await expect(find(source)).resolves.toEqual({ + number: 7, + title: 'Simplify single tool', + url: 'https://github.com/o/r/pull/7', + additions: 12, + deletions: 3, + state: 'open', + isDraft: false, + mergeable: 'clean', + checks: 'passing', + }); + }); + + it('reports conflicts only while the pull request is open', async () => { + const open = sourceFor(routes(pull({ mergeable: false }))); + await expect(find(open.source)).resolves.toMatchObject({ mergeable: 'conflicting' }); + const merged = sourceFor(routes(pull({ state: 'closed', merged: true, mergeable: false }))); + await expect(find(merged.source)).resolves.toMatchObject({ + state: 'merged', + mergeable: 'unknown', + }); + }); + + it('keeps mergeable unknown while GitHub is still computing it', async () => { + const { source } = sourceFor(routes(pull({ mergeable: null }))); + await expect(find(source)).resolves.toMatchObject({ mergeable: 'unknown' }); + }); + + it('carries draft state', async () => { + const { source } = sourceFor(routes(pull({ draft: true }))); + await expect(find(source)).resolves.toMatchObject({ isDraft: true }); + }); + + it('prefers the open pull request over a newer closed one', async () => { + const { source, fetchFn } = sourceFor({ + '/pulls?state=all': () => + json([ + { number: 9, state: 'closed', head: { sha } }, + { number: 7, state: 'open', head: { sha } }, + ]), + '/pulls/7': () => json(pull()), + '/check-runs': () => json({ check_runs: [] }), + }); + await expect(find(source)).resolves.toMatchObject({ number: 7 }); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/pulls/9'))).toBe(false); + }); + + it('returns null when the branch has no pull request or the repository is not visible', async () => { + const none = sourceFor({ '/pulls?state=all': () => json([]) }); + await expect(find(none.source)).resolves.toBeNull(); + const hidden = sourceFor({ '/pulls?state=all': () => new Response('', { status: 404 }) }); + await expect(find(hidden.source)).resolves.toBeNull(); + }); + + it('queries only a plain owner/name and never contacts GitHub otherwise', async () => { + const { source, fetchFn } = sourceFor({}); + await expect(source.find({ repo: 'o/r', branch: '', token: 't' })).resolves.toBeNull(); + for (const repo of ['../x', 'o/..', './x', 'o/.', 'a b/c', 'o', 'o/r/extra']) { + await expect(source.find({ repo, branch: 'main', token: 't' })).resolves.toBeNull(); + } + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('encodes the branch in the head filter', async () => { + const { source, fetchFn } = sourceFor({ '/pulls?state=all': () => json([]) }); + await source.find({ repo: 'o/r', branch: 'feat/a&b=c', token: 't' }); + const url = String(fetchFn.mock.calls[0][0]); + expect(url).toContain('head=o%3Afeat%2Fa%26b%3Dc'); + }); + + it.each([ + ['a 429', new Response('', { status: 429 })], + [ + 'a 403 with no quota left', + new Response('', { status: 403, headers: { 'x-ratelimit-remaining': '0' } }), + ], + ])('maps %s to RATE_LIMITED', async (_label, response) => { + const { source } = sourceFor({ '/pulls?state=all': () => response }); + await expect(find(source)).rejects.toMatchObject({ code: 'RATE_LIMITED' }); + }); + + it.each([ + ['a 500', () => new Response('boom', { status: 500 })], + ['a plain 403', () => new Response('', { status: 403 })], + ['a malformed list', () => json({ not: 'a list' })], + ])('maps %s to UPSTREAM_ERROR', async (_label, respond) => { + const { source } = sourceFor({ '/pulls?state=all': respond }); + await expect(find(source)).rejects.toBeInstanceOf(PullRequestSourceError); + await expect(find(source)).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + }); + + it('maps a network failure to UPSTREAM_ERROR without keeping its text', async () => { + const source = createGitHubPullRequestSource({ + fetchFn: jest.fn().mockRejectedValue(new Error('connect ECONNREFUSED token=secret')), + }); + const error = await find(source).catch((caught: Error) => caught); + expect(error).toMatchObject({ code: 'UPSTREAM_ERROR' }); + expect(String((error as Error).message)).not.toContain('secret'); + }); + + it.each([ + ['a non-github host', 'https://evil.example/o/r/pull/7'], + ['a javascript url', 'javascript:alert(1)'], + ['plain http', 'http://github.com/o/r/pull/7'], + ])('rejects %s as the pull request page', async (_label, html_url) => { + const { source } = sourceFor(routes(pull({ html_url }))); + await expect(find(source)).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + }); + + it('bounds the title', async () => { + const { source } = sourceFor(routes(pull({ title: 'x'.repeat(1000) }))); + const result = await find(source); + expect(result?.title).toHaveLength(256); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts new file mode 100644 index 00000000000..af7c73d5e6f --- /dev/null +++ b/packages/api/src/pulls/github.ts @@ -0,0 +1,226 @@ +import type { + PullRequestChecks, + PullRequestMergeable, + PullRequestState, + TConversationPullRequest, +} from 'librechat-data-provider'; +import type { PullRequestSource } from './types'; +import { PullRequestSourceError } from './types'; + +const GITHUB_API_BASE = 'https://api.github.com'; +const REQUEST_TIMEOUT_MS = 10_000; +const MAX_TITLE_LENGTH = 256; +const REPO_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; +const FAILING_CONCLUSIONS = new Set([ + 'failure', + 'timed_out', + 'cancelled', + 'action_required', + 'startup_failure', + 'stale', +]); + +/** The only part of `fetch` this module calls, so any compatible client can be injected. */ +export type PullRequestFetch = (input: string, init?: RequestInit) => Promise; + +type GitHubPull = { + number: number; + title: string; + url: string; + state: 'open' | 'closed'; + merged: boolean; + draft: boolean; + additions: number; + deletions: number; + mergeable: boolean | null; +}; + +type GitHubCheckRun = { status: string; conclusion: string | null }; + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +/** The URL ends up in an href, so only a plain https github.com page is accepted. */ +function isGitHubPageUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.hostname === 'github.com'; + } catch { + return false; + } +} + +function parsePull(value: unknown): GitHubPull { + if ( + !isRecord(value) || + !isCount(value.number) || + typeof value.title !== 'string' || + !isGitHubPageUrl(value.html_url) || + (value.state !== 'open' && value.state !== 'closed') || + typeof value.merged !== 'boolean' || + typeof value.draft !== 'boolean' || + !isCount(value.additions) || + !isCount(value.deletions) || + (value.mergeable !== null && typeof value.mergeable !== 'boolean') + ) { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + return { + number: value.number, + title: value.title, + url: value.html_url, + state: value.state, + merged: value.merged, + draft: value.draft, + additions: value.additions, + deletions: value.deletions, + mergeable: value.mergeable, + }; +} + +function parseListItem(value: unknown): { number: number; state: string; sha: string } { + if ( + !isRecord(value) || + !isCount(value.number) || + typeof value.state !== 'string' || + !isRecord(value.head) || + typeof value.head.sha !== 'string' || + !/^[a-f0-9]{40,64}$/.test(value.head.sha) + ) { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + return { number: value.number, state: value.state, sha: value.head.sha }; +} + +function parseCheckRuns(value: unknown): GitHubCheckRun[] { + if (!isRecord(value) || !Array.isArray(value.check_runs)) { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + return value.check_runs.map((run) => { + if (!isRecord(run) || typeof run.status !== 'string') { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + return { + status: run.status, + conclusion: typeof run.conclusion === 'string' ? run.conclusion : null, + }; + }); +} + +/** A failed check outranks one still running, so the header turns red as soon as it is known. */ +export function summarizeChecks(runs: readonly GitHubCheckRun[]): PullRequestChecks { + if (runs.length === 0) return 'none'; + if (runs.some((run) => run.conclusion != null && FAILING_CONCLUSIONS.has(run.conclusion))) { + return 'failing'; + } + if (runs.some((run) => run.status !== 'completed')) return 'running'; + return 'passing'; +} + +function summarizeState(pull: GitHubPull): PullRequestState { + return pull.merged ? 'merged' : pull.state; +} + +/** Conflict state only means something while the pull request is open. */ +function summarizeMergeable(pull: GitHubPull): PullRequestMergeable { + if (summarizeState(pull) !== 'open' || pull.mergeable == null) return 'unknown'; + return pull.mergeable ? 'clean' : 'conflicting'; +} + +export function toConversationPullRequest( + pull: GitHubPull, + runs: readonly GitHubCheckRun[], +): TConversationPullRequest { + return { + number: pull.number, + title: pull.title.slice(0, MAX_TITLE_LENGTH), + url: pull.url, + additions: pull.additions, + deletions: pull.deletions, + state: summarizeState(pull), + isDraft: pull.draft, + mergeable: summarizeMergeable(pull), + checks: summarizeChecks(runs), + }; +} + +function isRateLimited(response: Response): boolean { + return ( + response.status === 429 || + (response.status === 403 && + (response.headers.get('x-ratelimit-remaining') === '0' || + response.headers.get('retry-after') != null)) + ); +} + +/** + * Reads pull requests through GitHub's REST API with a token the caller supplies. Only the + * decision (absent, rate limited, failed) leaves this module; response bodies and error text + * never do. + */ +/** `.` and `..` match the character class but would rewrite the API path. */ +const isPathSegment = (value: string): boolean => value !== '.' && value !== '..'; + +export function createGitHubPullRequestSource({ + fetchFn = fetch, + apiBase = GITHUB_API_BASE, +}: { fetchFn?: PullRequestFetch; apiBase?: string } = {}): PullRequestSource { + async function getJson(pathname: string, token: string): Promise { + let response: Response; + try { + response = await fetchFn(`${apiBase}${pathname}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'LibreChat-Pull-Requests', + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + if (response.ok) { + try { + return await response.json(); + } catch { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + } + if (isRateLimited(response)) throw new PullRequestSourceError('RATE_LIMITED'); + /** A repository the token cannot see is indistinguishable from one without pull requests. */ + if (response.status === 404) return null; + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + + return { + async find({ repo, branch, token }) { + const match = REPO_PATTERN.exec(repo); + if (match == null || branch.length === 0) return null; + const [, owner, name] = match; + if (!isPathSegment(owner) || !isPathSegment(name)) return null; + const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; + const head = encodeURIComponent(`${owner}:${branch}`); + const listed = await getJson( + `${base}/pulls?state=all&sort=updated&direction=desc&per_page=10&head=${head}`, + token, + ); + if (listed == null) return null; + if (!Array.isArray(listed)) throw new PullRequestSourceError('UPSTREAM_ERROR'); + const candidates = listed.map(parseListItem); + const chosen = candidates.find((item) => item.state === 'open') ?? candidates[0]; + if (chosen == null) return null; + + const [pull, runs] = await Promise.all([ + getJson(`${base}/pulls/${chosen.number}`, token), + getJson(`${base}/commits/${chosen.sha}/check-runs?per_page=100`, token), + ]); + if (pull == null) return null; + return toConversationPullRequest(parsePull(pull), runs == null ? [] : parseCheckRuns(runs)); + }, + }; +} diff --git a/packages/api/src/pulls/handler.spec.ts b/packages/api/src/pulls/handler.spec.ts new file mode 100644 index 00000000000..7032920a91c --- /dev/null +++ b/packages/api/src/pulls/handler.spec.ts @@ -0,0 +1,157 @@ +import type { TConversationPullRequest } from 'librechat-data-provider'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types'; +import { createConversationPullRequestHandler, resolveTokenReference } from './handler'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn() }, +})); + +const pr: TConversationPullRequest = { + number: 7, + title: 't', + url: 'https://github.com/o/r/pull/7', + additions: 1, + deletions: 2, + state: 'open', + isDraft: false, + mergeable: 'clean', + checks: 'passing', +}; + +const enabled = { enabled: true, token: '${GH_TOKEN}', cacheTtlSeconds: 30 }; +const configWith = (pullRequests?: Record) => ({ + endpoints: { agents: { pullRequests } }, +}); + +type Lane = { branch: string | null; head: string | null; repo?: string } | null; + +function setup( + options: { + settings?: Record | null; + laneGit?: Lane; + lookup?: jest.Mock; + env?: Record; + } = {}, +) { + /** `null` means the block is absent; omitting the option means the enabled default. */ + const settings = options.settings === undefined ? enabled : (options.settings ?? undefined); + const laneGit: Lane = + options.laneGit === undefined ? { branch: 'feat/x', head: null, repo: 'o/r' } : options.laneGit; + const lookup = options.lookup ?? jest.fn().mockResolvedValue({ ok: true, value: pr }); + const env = options.env ?? { GH_TOKEN: 'ghp_secret' }; + const getConvoLaneGit = jest.fn().mockResolvedValue(laneGit); + const handler = createConversationPullRequestHandler({ + getConvoLaneGit, + getAppConfig: jest.fn().mockResolvedValue(configWith(settings)), + lookup, + env, + }); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + const run = (params: { conversationId?: string } = { conversationId: 'c1' }, user = 'u1') => + handler({ user: { id: user }, params } as unknown as ServerRequest, res as unknown as Response); + return { run, res, getConvoLaneGit, lookup }; +} + +describe('resolveTokenReference', () => { + it.each([ + ['${GH_TOKEN}', { GH_TOKEN: ' abc ' }, 'abc'], + ['${GH_TOKEN}', {}, null], + ['${GH_TOKEN}', { GH_TOKEN: ' ' }, null], + ['literal-token', { 'literal-token': 'x' }, null], + [undefined, { GH_TOKEN: 'x' }, null], + ])('resolves %s', (reference, env, expected) => { + expect(resolveTokenReference(reference, env)).toBe(expected); + }); +}); + +describe('createConversationPullRequestHandler', () => { + it('returns the pull request for the owner-scoped branch', async () => { + const { run, res, getConvoLaneGit, lookup } = setup(); + await run(); + expect(getConvoLaneGit).toHaveBeenCalledWith('u1', 'c1'); + expect(lookup).toHaveBeenCalledWith({ + repo: 'o/r', + branch: 'feat/x', + token: 'ghp_secret', + ttlMs: 30_000, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ pullRequest: pr }); + }); + + it('answers null without reading anything when the feature is off', async () => { + for (const settings of [null, { enabled: false }]) { + const { run, res, getConvoLaneGit } = setup({ settings }); + await run(); + expect(getConvoLaneGit).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ pullRequest: null }); + } + }); + + it.each([ + ['no stored lane', null], + ['a detached lane', { branch: null, head: null, repo: 'o/r' }], + ['a lane with no repository', { branch: 'main', head: null }], + ])('answers null for %s without calling GitHub', async (_label, laneGit) => { + const { run, res, lookup } = setup({ laneGit }); + await run(); + expect(lookup).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ pullRequest: null }); + }); + + it('answers null when the branch has no pull request', async () => { + const { run, res } = setup({ lookup: jest.fn().mockResolvedValue({ ok: true, value: null }) }); + await run(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ pullRequest: null }); + }); + + it('is a 404 for a missing or oversized conversation id', async () => { + for (const conversationId of [undefined, '', ' ', 'x'.repeat(257)]) { + const { run, res, getConvoLaneGit } = setup(); + await run({ conversationId }); + expect(res.status).toHaveBeenCalledWith(404); + expect(getConvoLaneGit).not.toHaveBeenCalled(); + } + }); + + it('reports a missing token as NOT_CONFIGURED without leaking config', async () => { + const { run, res, lookup } = setup({ env: {} }); + await run(); + expect(lookup).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ + error: 'Pull requests are not configured', + code: 'NOT_CONFIGURED', + }); + }); + + it.each(['RATE_LIMITED', 'UPSTREAM_ERROR'])( + 'maps a %s failure to a 503 with its code', + async (code) => { + const { run, res } = setup({ + lookup: jest.fn().mockResolvedValue({ ok: false, error: { code } }), + }); + await run(); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code })); + }, + ); + + it('answers 500 with fixed text when storage throws, never the exception', async () => { + const { run, res, getConvoLaneGit } = setup(); + getConvoLaneGit.mockRejectedValue(new Error('mongodb://user:secret@host')); + await run(); + expect(res.status).toHaveBeenCalledWith(500); + expect(JSON.stringify(res.json.mock.calls)).not.toContain('secret'); + }); + + it('never puts the token in a response', async () => { + const { run, res } = setup(); + await run(); + expect(JSON.stringify(res.json.mock.calls)).not.toContain('ghp_secret'); + }); +}); diff --git a/packages/api/src/pulls/handler.ts b/packages/api/src/pulls/handler.ts new file mode 100644 index 00000000000..2f2c5bb35d8 --- /dev/null +++ b/packages/api/src/pulls/handler.ts @@ -0,0 +1,95 @@ +import { logger } from '@librechat/data-schemas'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TAgentsEndpoint, TConversationPullRequestResponse } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { GetAppConfigOptions } from '~/app/service'; +import type { PullRequestLookup } from './types'; +import type { ServerRequest } from '~/types'; +import { getAppConfigOptionsFromUser } from '~/app/service'; +import { getSafeErrorMetadata } from '~/utils'; + +const MAX_CONVERSATION_ID_LENGTH = 256; +const TOKEN_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + +export type ConversationLaneGit = { branch: string | null; head: string | null; repo?: string }; + +/** Resolves `${NAME}` against the environment; the config never holds the token itself. */ +export function resolveTokenReference( + reference: string | undefined, + env: Readonly>, +): string | null { + const name = reference == null ? undefined : TOKEN_REFERENCE.exec(reference)?.[1]; + if (name == null) return null; + return env[name]?.trim() || null; +} + +const validConversationId = (value: string | undefined): value is string => + value != null && value.trim() !== '' && value.length <= MAX_CONVERSATION_ID_LENGTH; + +const NONE: TConversationPullRequestResponse = { pullRequest: null }; + +/** + * Serves the pull request of the branch a conversation's code workspace last reported. The + * stored branch is read owner-scoped, so another user's conversation is indistinguishable from + * one without a pull request. Failures answer with a stable code and never with upstream text. + */ +export function createConversationPullRequestHandler(deps: { + getConvoLaneGit: (user: string, conversationId: string) => Promise; + getAppConfig: (options: GetAppConfigOptions) => Promise; + lookup: PullRequestLookup; + env: Readonly>; +}) { + return async (req: ServerRequest, res: Response): Promise => { + const userId = req.user?.id; + const { conversationId } = req.params as { conversationId?: string }; + if (!userId || !validConversationId(conversationId)) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + try { + const appConfig = await deps.getAppConfig({ + ...getAppConfigOptionsFromUser(req.user), + skipRuntimeAugmentation: true, + failClosed: true, + }); + const settings = (appConfig.endpoints?.[EModelEndpoint.agents] as TAgentsEndpoint | undefined) + ?.pullRequests; + if (settings?.enabled !== true) { + res.status(200).json(NONE); + return; + } + + const laneGit = await deps.getConvoLaneGit(userId, conversationId); + if (laneGit?.branch == null || laneGit.repo == null) { + res.status(200).json(NONE); + return; + } + + const token = resolveTokenReference(settings.token, deps.env); + if (token == null) { + logger.warn('[PullRequests] Enabled without a usable token reference'); + res.status(503).json({ error: 'Pull requests are not configured', code: 'NOT_CONFIGURED' }); + return; + } + + const result = await deps.lookup({ + repo: laneGit.repo, + branch: laneGit.branch, + token, + ttlMs: (settings.cacheTtlSeconds ?? 30) * 1000, + }); + if (!result.ok) { + res.status(503).json({ + error: 'Pull request lookup is unavailable', + code: result.error.code, + }); + return; + } + res.status(200).json({ pullRequest: result.value }); + } catch (error) { + logger.error('[PullRequests] Handler failed', getSafeErrorMetadata(error)); + res.status(500).json({ error: 'Failed to load the pull request' }); + } + }; +} diff --git a/packages/api/src/pulls/index.ts b/packages/api/src/pulls/index.ts new file mode 100644 index 00000000000..f6fca526708 --- /dev/null +++ b/packages/api/src/pulls/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './github'; +export * from './lookup'; +export * from './handler'; diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts new file mode 100644 index 00000000000..9f504a14096 --- /dev/null +++ b/packages/api/src/pulls/lookup.spec.ts @@ -0,0 +1,105 @@ +import type { TConversationPullRequest } from 'librechat-data-provider'; +import type { PullRequestSource } from './types'; +import { createPullRequestLookup } from './lookup'; +import { PullRequestSourceError } from './types'; + +const value: TConversationPullRequest = { + number: 1, + title: 't', + url: 'https://github.com/o/r/pull/1', + additions: 1, + deletions: 0, + state: 'open', + isDraft: false, + mergeable: 'clean', + checks: 'passing', +}; +const input = { repo: 'o/r', branch: 'main', token: 't', ttlMs: 30_000 }; + +describe('createPullRequestLookup', () => { + it('reuses a result within its lifetime and asks again after it', async () => { + let clock = 0; + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await expect(lookup(input)).resolves.toEqual({ ok: true, value }); + clock = 29_999; + await lookup(input); + expect(find).toHaveBeenCalledTimes(1); + clock = 30_001; + await lookup(input); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('caches a documented absence', async () => { + const find = jest.fn().mockResolvedValue(null); + const lookup = createPullRequestLookup({ source: { find } }); + await expect(lookup(input)).resolves.toEqual({ ok: true, value: null }); + await lookup(input); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('shares one request between concurrent callers', async () => { + let release: (pr: TConversationPullRequest) => void = () => undefined; + const find = jest.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const lookup = createPullRequestLookup({ source: { find } }); + const first = lookup(input); + const second = lookup(input); + release(value); + await expect(Promise.all([first, second])).resolves.toEqual([ + { ok: true, value }, + { ok: true, value }, + ]); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('keys by repository and branch', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup(input); + await lookup({ ...input, branch: 'other' }); + await lookup({ ...input, repo: 'o/other' }); + expect(find).toHaveBeenCalledTimes(3); + }); + + it('returns a failure as a coded result and retries it sooner than a success', async () => { + let clock = 0; + const find = jest + .fn() + .mockRejectedValueOnce(new PullRequestSourceError('RATE_LIMITED')) + .mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await expect(lookup(input)).resolves.toEqual({ + ok: false, + error: { code: 'RATE_LIMITED' }, + }); + clock = 5_000; + await expect(lookup(input)).resolves.toMatchObject({ ok: false }); + expect(find).toHaveBeenCalledTimes(1); + clock = 10_001; + await expect(lookup(input)).resolves.toEqual({ ok: true, value }); + }); + + it('turns an unexpected exception into UPSTREAM_ERROR without its text', async () => { + const source: PullRequestSource = { + find: jest.fn().mockRejectedValue(new Error('mongodb://user:secret@host')), + }; + const result = await createPullRequestLookup({ source })(input); + expect(result).toEqual({ ok: false, error: { code: 'UPSTREAM_ERROR' } }); + expect(JSON.stringify(result)).not.toContain('secret'); + }); + + it('evicts the oldest entry past its bound', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, maxEntries: 2 }); + await lookup({ ...input, branch: 'a' }); + await lookup({ ...input, branch: 'b' }); + await lookup({ ...input, branch: 'c' }); + await lookup({ ...input, branch: 'a' }); + expect(find).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts new file mode 100644 index 00000000000..884cbbe67cc --- /dev/null +++ b/packages/api/src/pulls/lookup.ts @@ -0,0 +1,64 @@ +import { logger } from '@librechat/data-schemas'; +import type { PullRequestLookup, PullRequestLookupResult, PullRequestSource } from './types'; +import { PullRequestSourceError } from './types'; +import { getSafeErrorMetadata } from '~/utils'; + +/** A failed lookup is remembered briefly so a rate limit is not hammered by every poll. */ +const FAILURE_TTL_MS = 10_000; +const DEFAULT_MAX_ENTRIES = 500; + +type Entry = { result: PullRequestLookupResult; expiresAt: number }; + +/** + * Caches lookups per repository branch and shares one in-flight request between concurrent + * callers. A pull request is repository data, not user data: callers must have authorized the + * conversation before asking, and the cache is keyed only by what GitHub is asked about. + */ +export function createPullRequestLookup({ + source, + now = Date.now, + maxEntries = DEFAULT_MAX_ENTRIES, +}: { + source: PullRequestSource; + now?: () => number; + maxEntries?: number; +}): PullRequestLookup { + const entries = new Map(); + const inflight = new Map>(); + + function remember(key: string, result: PullRequestLookupResult, ttlMs: number): void { + const lifetime = result.ok ? ttlMs : Math.min(ttlMs, FAILURE_TTL_MS); + entries.delete(key); + entries.set(key, { result, expiresAt: now() + lifetime }); + while (entries.size > maxEntries) { + const oldest = entries.keys().next(); + if (oldest.done) break; + entries.delete(oldest.value); + } + } + + return async ({ repo, branch, token, ttlMs }) => { + const key = `${repo}#${branch}`; + const cached = entries.get(key); + if (cached != null && cached.expiresAt > now()) return cached.result; + const pending = inflight.get(key); + if (pending != null) return pending; + + const run = (async (): Promise => { + let result: PullRequestLookupResult; + try { + result = { ok: true, value: await source.find({ repo, branch, token }) }; + } catch (error) { + if (!(error instanceof PullRequestSourceError)) { + logger.warn('[PullRequests] Lookup failed', getSafeErrorMetadata(error)); + } + const code = error instanceof PullRequestSourceError ? error.code : 'UPSTREAM_ERROR'; + result = { ok: false, error: { code } }; + } + remember(key, result, ttlMs); + return result; + })().finally(() => inflight.delete(key)); + inflight.set(key, run); + return run; + }; +} diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts new file mode 100644 index 00000000000..90026dd9d55 --- /dev/null +++ b/packages/api/src/pulls/types.ts @@ -0,0 +1,37 @@ +import type { TConversationPullRequest } from 'librechat-data-provider'; + +/** Stable, user-safe reasons a lookup could not answer. None carries upstream text. */ +export const PULL_REQUEST_ERROR_CODES = [ + 'NOT_CONFIGURED', + 'RATE_LIMITED', + 'UPSTREAM_ERROR', +] as const; +export type PullRequestErrorCode = (typeof PULL_REQUEST_ERROR_CODES)[number]; + +/** Thrown by a source for an operational failure; the lookup translates it into a result. */ +export class PullRequestSourceError extends Error { + constructor(public readonly code: PullRequestErrorCode) { + super(`Pull request lookup failed: ${code}`); + this.name = 'PullRequestSourceError'; + } +} + +export type PullRequestFindInput = { + /** `owner/name`. */ + repo: string; + branch: string; + token: string; +}; + +/** Finds the pull request for a branch. Null is a documented absence, not a failure. */ +export type PullRequestSource = { + find(input: PullRequestFindInput): Promise; +}; + +export type PullRequestLookupResult = + | { ok: true; value: TConversationPullRequest | null } + | { ok: false; error: { code: PullRequestErrorCode } }; + +export type PullRequestLookupInput = PullRequestFindInput & { ttlMs: number }; + +export type PullRequestLookup = (input: PullRequestLookupInput) => Promise; diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 3498cf9e7fa..a48f2e4be91 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -9359,6 +9359,126 @@ describe('Conversation Operations', () => { }); }); +describe('laneGit', () => { + const seed = async (user = 'lane-user') => { + const conversationId = uuidv4(); + await Conversation.create({ conversationId, user, title: 'Lane test', endpoint: 'agents' }); + return conversationId; + }; + const head = 'a'.repeat(40); + + it('stores the reported branch and head for the owner and reads them back', async () => { + const conversationId = await seed(); + const laneGit = { branch: 'feat/pr-chip', head }; + await expect( + methods.setConvoLaneGit({ user: 'lane-user', conversationId, laneGit }), + ).resolves.toBe(true); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual(laneGit); + }); + + it('does not write for another owner or an unknown conversation', async () => { + const conversationId = await seed(); + const laneGit = { branch: 'main', head }; + await expect( + methods.setConvoLaneGit({ user: 'intruder', conversationId, laneGit }), + ).resolves.toBe(false); + await expect( + methods.setConvoLaneGit({ user: 'lane-user', conversationId: uuidv4(), laneGit }), + ).resolves.toBe(false); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toBeNull(); + await expect(methods.getConvoLaneGit('intruder', conversationId)).resolves.toBeNull(); + }); + + it('reports no change when the same state is recorded again', async () => { + const conversationId = await seed(); + const input = { user: 'lane-user', conversationId, laneGit: { branch: 'main', head } }; + await expect(methods.setConvoLaneGit(input)).resolves.toBe(true); + await expect(methods.setConvoLaneGit(input)).resolves.toBe(false); + }); + + it('replaces the stored state when the branch or head changes', async () => { + const conversationId = await seed(); + const user = 'lane-user'; + await methods.setConvoLaneGit({ user, conversationId, laneGit: { branch: 'main', head } }); + const next = { branch: 'feat/x', head: 'b'.repeat(40) }; + await expect(methods.setConvoLaneGit({ user, conversationId, laneGit: next })).resolves.toBe( + true, + ); + await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(next); + }); + + it('stores the repository with the lane and treats a repository change as a change', async () => { + const conversationId = await seed(); + const user = 'lane-user'; + const laneGit = { branch: 'main', head }; + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/r' }), + ).resolves.toBe(true); + await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual({ + ...laneGit, + repo: 'o/r', + }); + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/r' }), + ).resolves.toBe(false); + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/other' }), + ).resolves.toBe(true); + }); + + it('keeps a detached or empty lane distinct from an unknown one', async () => { + const conversationId = await seed(); + const laneGit = { branch: null, head: null }; + await expect( + methods.setConvoLaneGit({ user: 'lane-user', conversationId, laneGit }), + ).resolves.toBe(true); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual(laneGit); + }); + + describe('server-written only', () => { + const kept = { branch: 'kept', head }; + const seedWithLane = async () => { + const conversationId = await seed(); + await methods.setConvoLaneGit({ user: 'lane-user', conversationId, laneGit: kept }); + return conversationId; + }; + + it('is not overwritten by a generic save', async () => { + const conversationId = await seedWithLane(); + await saveConvo( + { userId: 'lane-user' }, + { conversationId, laneGit: { branch: 'forged', head: null } }, + ); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual(kept); + }); + + it('is not cleared by a generic save', async () => { + const conversationId = await seedWithLane(); + await saveConvo({ userId: 'lane-user' }, { conversationId }, { unsetFields: { laneGit: 1 } }); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual(kept); + }); + + it('is not accepted from a bulk import', async () => { + const imported = uuidv4(); + await methods.bulkSaveConvos([ + { + conversationId: imported, + user: 'lane-user', + title: 'Imported', + laneGit: { branch: 'x', head: null }, + }, + ]); + await expect(methods.getConvoLaneGit('lane-user', imported)).resolves.toBeNull(); + }); + + it('is excluded from ordinary conversation reads', async () => { + const conversationId = await seedWithLane(); + const read = await Conversation.findOne({ conversationId }).lean(); + expect(read).not.toHaveProperty('laneGit'); + }); + }); +}); + describe('stampForcedRetention', () => { const ephemeral = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }; let userId: string; diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 6baa5c04427..989ea8f8a7f 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -329,6 +329,16 @@ export interface ConversationMethods { IConversation, 'conversationId' | 'codeEnvironmentMode' | 'codeWorkspaces' | 'codeEnvironmentRevision' > | null>; + setConvoLaneGit(input: { + user: string; + conversationId: string; + laneGit: Pick, 'branch' | 'head'>; + repo?: string; + }): Promise; + getConvoLaneGit( + user: string, + conversationId: string, + ): Promise | null>; addConvoToolApprovalAllows(input: { user: string; conversationId: string; @@ -2452,6 +2462,7 @@ export function createConversationMethods( } /* Remembered tool approvals are granted only by a validated resume. */ delete update.toolApprovalAllows; + delete update.laneGit; /** Ordinary saves may seed a decision, but only an explicit move may replace it. */ const decisionOnInsert = { ...(convo.codeEnvironmentMode != null && { @@ -2477,6 +2488,7 @@ export function createConversationMethods( delete unsetFields.titleSetByUser; delete unsetFields.titleRevision; delete unsetFields.toolApprovalAllows; + delete unsetFields.laneGit; delete unsetFields.codeEnvironmentRevision; delete unsetFields.codeEnvironmentMode; delete unsetFields.codeWorkspaces; @@ -3003,6 +3015,55 @@ export function createConversationMethods( return result.matchedCount === 1; } + /** + * Record the branch and head a conversation's code lane last reported. Owner-scoped and + * conditional on a change, so a lane that keeps reporting the same state costs no write. + * Server-written only: generic saves and imports cannot set it. Resolves to whether the + * stored value changed; false means unchanged or no such conversation for this owner. + */ + async function setConvoLaneGit({ + user, + conversationId, + laneGit, + repo, + }: { + user: string; + conversationId: string; + laneGit: Pick, 'branch' | 'head'>; + repo?: string; + }): Promise { + const Conversation = mongoose.models.Conversation as Model; + const next = { + branch: laneGit.branch, + head: laneGit.head, + ...(repo ? { repo } : {}), + }; + const result = await withoutMeiliIndexing( + Conversation.updateOne( + { + user, + conversationId, + $expr: { $ne: [{ $ifNull: ['$laneGit', null] }, { $literal: next }] }, + }, + { $set: { laneGit: next } }, + { timestamps: false }, + ), + ); + return result.modifiedCount === 1; + } + + /** The last reported lane state, or null when the conversation has none or is not the owner's. */ + async function getConvoLaneGit( + user: string, + conversationId: string, + ): Promise | null> { + const Conversation = mongoose.models.Conversation as Model; + const stored = await Conversation.findOne({ user, conversationId }) + .select('laneGit') + .lean | null>(); + return stored?.laneGit ?? null; + } + async function readAdmittedConvoCodeEnvironmentDecision(user: string, conversationId: string) { const Conversation = mongoose.models.Conversation as Model; return withoutMeiliIndexing( @@ -3150,6 +3211,7 @@ export function createConversationMethods( delete sanitized.codeApprovalMode; delete sanitized.initial_agent_id; delete sanitized.toolApprovalAllows; + delete sanitized.laneGit; delete sanitized.codeEnvironmentRevision; stripActorCheckpointFields(sanitized); if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') { @@ -4232,6 +4294,8 @@ export function createConversationMethods( appendConvoMessageReference, getConvoCodeEnvironmentDecision, addConvoToolApprovalAllows, + setConvoLaneGit, + getConvoLaneGit, readAdmittedConvoCodeEnvironmentDecision, replaceConvoCodeEnvironmentDecision, bulkSaveConvos, diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index c0d940bbcf4..150dce16ff9 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -47,6 +47,18 @@ const convoSchema: Schema = new Schema( /** Exact tool names the owner chose to auto-approve for this conversation. * Server-written only, through `addConvoToolApprovalAllows`. */ toolApprovalAllows: { type: [String], default: undefined }, + /** Branch and head of the code lane, as the worker last reported them. Server-written only, + * through `setConvoLaneGit`, and excluded from ordinary conversation reads. */ + laneGit: { + type: { + branch: { type: String, default: null }, + head: { type: String, default: null }, + repo: { type: String, default: undefined }, + }, + _id: false, + default: undefined, + select: false, + }, agent_id: { type: String, }, diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index 66b0b21dd78..e610c967377 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -21,6 +21,14 @@ export const MAX_AGENT_EVENT_ACTOR_ENCODING_LENGTH = 128; */ export const AGENT_EVENT_ACTOR_SUMMARY_VERSION = 1; +/** Branch and head commit the conversation's code lane last reported; null when it has none. */ +export interface IConversationLaneGit { + branch: string | null; + head: string | null; + /** `owner/name` of the lane's repository, as its worker registered it. */ + repo?: string; +} + export interface ISubagentThreadLease { token: string; taskId: string; @@ -290,6 +298,8 @@ export interface IConversation extends Document { codeWorkspaces?: CodeWorkspaceSelection[]; /** Exact tool names auto-approved for this conversation; server-written only. */ toolApprovalAllows?: string[]; + /** Last lane branch and head a command reported; server-written only and excluded from reads. */ + laneGit?: IConversationLaneGit; /** Immutable primary persisted-agent attribution for Insights. */ initial_agent_id?: string | null; subagentThread?: TSubagentThreadLineage; From 2415224389730b9cfc91d3090bdc172beb0a02b5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:52:28 +0200 Subject: [PATCH 02/10] fix: Address Review Findings on Pull Request Lookup and Lane Writes Scope the lookup cache, shared in-flight request and rate limit cooldown to the credential, so one tenant's token never serves another. Ask for open pull requests on their own before falling back to the newest closed one, read every page of check runs up to a bound and never report an incomplete rollup as passing, order lane writes per conversation so an older write cannot land last, and name both required token permissions in the example config. --- librechat.example.yaml | 4 +- packages/api/src/code/lane.spec.ts | 96 ++++++++++++++++++++ packages/api/src/code/lane.ts | 31 ++++++- packages/api/src/pulls/github.spec.ts | 124 +++++++++++++++++++++++--- packages/api/src/pulls/github.ts | 92 +++++++++++++++---- packages/api/src/pulls/lookup.spec.ts | 103 +++++++++++++++++++++ packages/api/src/pulls/lookup.ts | 41 +++++++-- packages/api/src/pulls/types.ts | 6 +- 8 files changed, 454 insertions(+), 43 deletions(-) diff --git a/librechat.example.yaml b/librechat.example.yaml index 9cb3d6456a2..2e0a2a3cae9 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -923,7 +923,9 @@ endpoints: # # Allow owners to cooperatively cancel ordinary background tools, including BYOM Bash. # ordinaryToolCancellation: false # # (optional) Show a conversation's pull request in the chat header. Off by default. It needs - # # a code worker that reports its branch, and a read-only GitHub token held on the server. + # # a code worker that reports its branch, and a GitHub token held on the server. Use a + # # fine-grained token limited to the repositories involved, with read-only "Pull requests" + # # and "Checks" permissions: without "Checks" every matched pull request fails to load. # pullRequests: # enabled: false # # Environment variable reference only, never the token itself. diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index a278793de64..f6660423023 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -132,3 +132,99 @@ describe('attached bash tool lane reporting', () => { expect(String(output)).toContain('ok'); }); }); + +describe('createLaneGitRecorder write order', () => { + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + function controlledWriter() { + const events: string[] = []; + const releases: Array<(ok?: boolean) => void> = []; + const fail: Array<() => void> = []; + const setConvoLaneGit = jest.fn( + ({ laneGit: reported }: { laneGit: { branch: string | null } }) => + new Promise((resolve, reject) => { + events.push(`start:${reported.branch}`); + releases.push((ok = true) => { + events.push(`end:${reported.branch}`); + resolve(ok); + }); + fail.push(() => reject(new Error('db down'))); + }), + ); + return { events, releases, fail, setConvoLaneGit }; + } + + it('starts a later write only after the earlier one has finished', async () => { + const { events, releases, setConvoLaneGit } = controlledWriter(); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-1', + setConvoLaneGit, + }); + const first = record?.({ branch: 'a', head }); + const second = record?.({ branch: 'b', head }); + await flush(); + expect(events).toEqual(['start:a']); + releases[0](); + await first; + await flush(); + expect(events).toEqual(['start:a', 'end:a', 'start:b']); + releases[1](); + await second; + expect(events).toEqual(['start:a', 'end:a', 'start:b', 'end:b']); + }); + + it('orders writes across recorders built for the same conversation', async () => { + const { events, releases, setConvoLaneGit } = controlledWriter(); + const make = () => + createLaneGitRecorder({ user: 'u1', conversationId: 'order-2', setConvoLaneGit }); + const first = make()?.({ branch: 'a', head }); + const second = make()?.({ branch: 'b', head }); + await flush(); + expect(events).toEqual(['start:a']); + releases[0](); + await first; + await flush(); + releases[1](); + await second; + expect(events).toEqual(['start:a', 'end:a', 'start:b', 'end:b']); + }); + + it('does not make one conversation wait for another', async () => { + const { events, setConvoLaneGit } = controlledWriter(); + const one = createLaneGitRecorder({ user: 'u1', conversationId: 'order-3', setConvoLaneGit }); + const other = createLaneGitRecorder({ user: 'u1', conversationId: 'order-4', setConvoLaneGit }); + void one?.({ branch: 'a', head }); + void other?.({ branch: 'b', head }); + await flush(); + expect(events).toEqual(['start:a', 'start:b']); + }); + + it('does not make one user wait for another on the same conversation id', async () => { + const { events, setConvoLaneGit } = controlledWriter(); + const make = (user: string) => + createLaneGitRecorder({ user, conversationId: 'order-5', setConvoLaneGit }); + void make('u1')?.({ branch: 'a', head }); + void make('u2')?.({ branch: 'b', head }); + await flush(); + expect(events).toEqual(['start:a', 'start:b']); + }); + + it('still runs the next write after one fails', async () => { + const { events, releases, fail, setConvoLaneGit } = controlledWriter(); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-6', + setConvoLaneGit, + }); + const first = record?.({ branch: 'a', head }); + const second = record?.({ branch: 'b', head }); + await flush(); + fail[0](); + await expect(first).resolves.toBe(false); + await flush(); + expect(events).toEqual(['start:a', 'start:b']); + releases[1](); + await expect(second).resolves.toBe(true); + }); +}); diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index d44315c43ec..d784dab35bd 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -18,12 +18,22 @@ const isSafeRepo = (repo: string | undefined): repo is string => REPO_PATTERN.test(repo) && repo.split('/').every((segment) => segment !== '.' && segment !== '..'); +/** + * The tail of each conversation's pending writes. A later command's report must not be applied + * before an earlier one finishes: database completion order is not request order, so an + * unordered older write could land last and leave the conversation on a stale branch. Entries + * leave the map as soon as their chain drains, so it only holds conversations with a write in + * flight. + */ +const pendingWrites = new Map>(); + /** * Records the lane state a finished command reported, for the owner's conversation. It never * throws and is never awaited by the command: the branch is a header affordance, so a database - * outage must not fail or delay the agent's tool call. Resolves to whether the stored value - * changed, which is false for an unchanged lane and for a conversation not saved yet. A repo that - * is not a plain `owner/name` is dropped rather than stored. + * outage must not fail or delay the agent's tool call. Writes for one conversation run in the + * order they were reported. Resolves to whether the stored value changed, which is false for an + * unchanged lane and for a conversation not saved yet. A repo that is not a plain `owner/name` + * is dropped rather than stored. */ export function createLaneGitRecorder({ user, @@ -38,7 +48,9 @@ export function createLaneGitRecorder({ }): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { if (!user || !conversationId) return undefined; const safeRepo = isSafeRepo(repo) ? repo : undefined; - return async (laneGit) => { + const queueKey = `${user}\0${conversationId}`; + + const write = async (laneGit: WorkspaceLaneGit): Promise => { try { return await setConvoLaneGit({ user, @@ -51,4 +63,15 @@ export function createLaneGitRecorder({ return false; } }; + + return (laneGit) => { + const previous = pendingWrites.get(queueKey) ?? Promise.resolve(); + /** `write` never rejects, so a failed write cannot stall the writes queued behind it. */ + const next = previous.then(() => write(laneGit)); + pendingWrites.set(queueKey, next); + void next.then(() => { + if (pendingWrites.get(queueKey) === next) pendingWrites.delete(queueKey); + }); + return next; + }; } diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index 4c9031ff289..e7d0233693b 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -51,7 +51,7 @@ describe('summarizeChecks', () => { describe('createGitHubPullRequestSource', () => { const routes = (pullBody = pull(), checks: unknown = { check_runs: [] }) => ({ - '/pulls?state=all': () => json(listed()), + '/pulls?state=open': () => json(listed()), '/pulls/7': () => json(pullBody), '/check-runs': () => json(checks), }); @@ -93,25 +93,37 @@ describe('createGitHubPullRequestSource', () => { await expect(find(source)).resolves.toMatchObject({ isDraft: true }); }); - it('prefers the open pull request over a newer closed one', async () => { + it('asks for the open pull request first, so closed history cannot hide it', async () => { const { source, fetchFn } = sourceFor({ - '/pulls?state=all': () => - json([ - { number: 9, state: 'closed', head: { sha } }, - { number: 7, state: 'open', head: { sha } }, - ]), + '/pulls?state=open': () => json(listed()), '/pulls/7': () => json(pull()), '/check-runs': () => json({ check_runs: [] }), }); await expect(find(source)).resolves.toMatchObject({ number: 7 }); - expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/pulls/9'))).toBe(false); + const urls = fetchFn.mock.calls.map(([url]) => String(url)); + expect(urls[0]).toContain('state=open'); + expect(urls.some((url) => url.includes('state=closed'))).toBe(false); + }); + + it('falls back to the most recently updated closed pull request when none is open', async () => { + const { source } = sourceFor({ + '/pulls?state=open': () => json([]), + '/pulls?state=closed': () => json([{ number: 9, state: 'closed', head: { sha } }]), + '/pulls/9': () => json(pull({ number: 9, state: 'closed', merged: true })), + '/check-runs': () => json({ check_runs: [] }), + }); + await expect(find(source)).resolves.toMatchObject({ number: 9, state: 'merged' }); }); it('returns null when the branch has no pull request or the repository is not visible', async () => { - const none = sourceFor({ '/pulls?state=all': () => json([]) }); + const none = sourceFor({ + '/pulls?state=open': () => json([]), + '/pulls?state=closed': () => json([]), + }); await expect(find(none.source)).resolves.toBeNull(); - const hidden = sourceFor({ '/pulls?state=all': () => new Response('', { status: 404 }) }); + const hidden = sourceFor({ '/pulls?state=open': () => new Response('', { status: 404 }) }); await expect(find(hidden.source)).resolves.toBeNull(); + expect(hidden.fetchFn).toHaveBeenCalledTimes(1); }); it('queries only a plain owner/name and never contacts GitHub otherwise', async () => { @@ -124,7 +136,10 @@ describe('createGitHubPullRequestSource', () => { }); it('encodes the branch in the head filter', async () => { - const { source, fetchFn } = sourceFor({ '/pulls?state=all': () => json([]) }); + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json([]), + '/pulls?state=closed': () => json([]), + }); await source.find({ repo: 'o/r', branch: 'feat/a&b=c', token: 't' }); const url = String(fetchFn.mock.calls[0][0]); expect(url).toContain('head=o%3Afeat%2Fa%26b%3Dc'); @@ -137,7 +152,7 @@ describe('createGitHubPullRequestSource', () => { new Response('', { status: 403, headers: { 'x-ratelimit-remaining': '0' } }), ], ])('maps %s to RATE_LIMITED', async (_label, response) => { - const { source } = sourceFor({ '/pulls?state=all': () => response }); + const { source } = sourceFor({ '/pulls?state=open': () => response }); await expect(find(source)).rejects.toMatchObject({ code: 'RATE_LIMITED' }); }); @@ -146,7 +161,7 @@ describe('createGitHubPullRequestSource', () => { ['a plain 403', () => new Response('', { status: 403 })], ['a malformed list', () => json({ not: 'a list' })], ])('maps %s to UPSTREAM_ERROR', async (_label, respond) => { - const { source } = sourceFor({ '/pulls?state=all': respond }); + const { source } = sourceFor({ '/pulls?state=open': respond }); await expect(find(source)).rejects.toBeInstanceOf(PullRequestSourceError); await expect(find(source)).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); }); @@ -175,3 +190,86 @@ describe('createGitHubPullRequestSource', () => { expect(result?.title).toHaveLength(256); }); }); + +describe('check runs across pages', () => { + const run = (conclusion: string | null, status = 'completed') => ({ status, conclusion }); + const passing = (count: number) => Array.from({ length: count }, () => run('success')); + + function sourceWithPages(pages: unknown[][], totalCount: number) { + const fetchFn = jest.fn(async (input: string) => { + const url = String(input); + if (url.includes('/pulls?state=open')) return json(listed()); + if (url.includes('/pulls/7')) return json(pull()); + if (url.includes('/check-runs')) { + const page = Number(new URL(url).searchParams.get('page') ?? '1'); + return json({ total_count: totalCount, check_runs: pages[page - 1] ?? [] }); + } + throw new Error(`unexpected ${url}`); + }); + return { fetchFn, source: createGitHubPullRequestSource({ fetchFn }) }; + } + + it('reads every page, so a failure past the first hundred still turns the rollup red', async () => { + const { source } = sourceWithPages([passing(100), [run('failure')]], 101); + await expect(find(source)).resolves.toMatchObject({ checks: 'failing' }); + }); + + it('reads every page, so a check still running past the first hundred is not called passing', async () => { + const { source } = sourceWithPages([passing(100), [run(null, 'in_progress')]], 101); + await expect(find(source)).resolves.toMatchObject({ checks: 'running' }); + }); + + it('stops after a bounded number of pages and never calls an incomplete rollup passing', async () => { + const pages = Array.from({ length: 60 }, () => passing(100)); + const { source, fetchFn } = sourceWithPages(pages, 6000); + await expect(find(source)).resolves.toMatchObject({ checks: 'running' }); + const checkCalls = fetchFn.mock.calls.filter(([url]) => String(url).includes('/check-runs')); + expect(checkCalls.length).toBeLessThanOrEqual(10); + }); + + it('makes one request when every check run fits on the first page', async () => { + const { source, fetchFn } = sourceWithPages([passing(3)], 3); + await expect(find(source)).resolves.toMatchObject({ checks: 'passing' }); + const checkCalls = fetchFn.mock.calls.filter(([url]) => String(url).includes('/check-runs')); + expect(checkCalls).toHaveLength(1); + }); +}); + +describe('rate limit back-off hint', () => { + it('carries retry-after as milliseconds', async () => { + const { source } = sourceFor({ + '/pulls?state=open': () => + new Response('', { status: 429, headers: { 'retry-after': '30' } }), + }); + await expect(find(source)).rejects.toMatchObject({ + code: 'RATE_LIMITED', + retryAfterMs: 30_000, + }); + }); + + it('derives the wait from the quota reset when there is no retry-after', async () => { + const reset = Math.floor(Date.now() / 1000) + 120; + const { source } = sourceFor({ + '/pulls?state=open': () => + new Response('', { + status: 403, + headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) }, + }), + }); + const error = (await find(source).catch((caught: unknown) => caught)) as { + code: string; + retryAfterMs?: number; + }; + expect(error.code).toBe('RATE_LIMITED'); + expect(error.retryAfterMs).toBeGreaterThan(100_000); + expect(error.retryAfterMs).toBeLessThanOrEqual(120_000); + }); + + it('leaves the hint out when GitHub gives none', async () => { + const { source } = sourceFor({ '/pulls?state=open': () => new Response('', { status: 429 }) }); + const error = (await find(source).catch((caught: unknown) => caught)) as { + retryAfterMs?: number; + }; + expect(error.retryAfterMs).toBeUndefined(); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index af7c73d5e6f..8f8709c037e 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -9,6 +9,9 @@ import { PullRequestSourceError } from './types'; const GITHUB_API_BASE = 'https://api.github.com'; const REQUEST_TIMEOUT_MS = 10_000; +const CHECK_RUN_PAGE_SIZE = 100; +/** Bounds the requests one lookup can make; past it the rollup is reported as still running. */ +const MAX_CHECK_RUN_PAGES = 10; const MAX_TITLE_LENGTH = 256; const REPO_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; const FAILING_CONCLUSIONS = new Set([ @@ -96,11 +99,13 @@ function parseListItem(value: unknown): { number: number; state: string; sha: st return { number: value.number, state: value.state, sha: value.head.sha }; } -function parseCheckRuns(value: unknown): GitHubCheckRun[] { +type CheckRunPage = { runs: GitHubCheckRun[]; total?: number }; + +function parseCheckRuns(value: unknown): CheckRunPage { if (!isRecord(value) || !Array.isArray(value.check_runs)) { throw new PullRequestSourceError('UPSTREAM_ERROR'); } - return value.check_runs.map((run) => { + const runs = value.check_runs.map((run) => { if (!isRecord(run) || typeof run.status !== 'string') { throw new PullRequestSourceError('UPSTREAM_ERROR'); } @@ -109,15 +114,22 @@ function parseCheckRuns(value: unknown): GitHubCheckRun[] { conclusion: typeof run.conclusion === 'string' ? run.conclusion : null, }; }); + return { runs, ...(isCount(value.total_count) ? { total: value.total_count } : {}) }; } -/** A failed check outranks one still running, so the header turns red as soon as it is known. */ -export function summarizeChecks(runs: readonly GitHubCheckRun[]): PullRequestChecks { - if (runs.length === 0) return 'none'; +/** + * A failed check outranks one still running, so the header turns red as soon as it is known. + * An `incomplete` rollup (more runs than were read) is never reported as passing. + */ +export function summarizeChecks( + runs: readonly GitHubCheckRun[], + incomplete = false, +): PullRequestChecks { + if (runs.length === 0 && !incomplete) return 'none'; if (runs.some((run) => run.conclusion != null && FAILING_CONCLUSIONS.has(run.conclusion))) { return 'failing'; } - if (runs.some((run) => run.status !== 'completed')) return 'running'; + if (incomplete || runs.some((run) => run.status !== 'completed')) return 'running'; return 'passing'; } @@ -134,6 +146,7 @@ function summarizeMergeable(pull: GitHubPull): PullRequestMergeable { export function toConversationPullRequest( pull: GitHubPull, runs: readonly GitHubCheckRun[], + incomplete = false, ): TConversationPullRequest { return { number: pull.number, @@ -144,7 +157,7 @@ export function toConversationPullRequest( state: summarizeState(pull), isDraft: pull.draft, mergeable: summarizeMergeable(pull), - checks: summarizeChecks(runs), + checks: summarizeChecks(runs, incomplete), }; } @@ -157,6 +170,19 @@ function isRateLimited(response: Response): boolean { ); } +/** How long GitHub asked callers to wait: `retry-after` seconds, else the quota reset time. */ +function retryAfterMs(response: Response): number | undefined { + const retryAfter = Number(response.headers.get('retry-after')); + if (response.headers.get('retry-after') != null && Number.isFinite(retryAfter)) { + return Math.max(0, retryAfter * 1000); + } + const reset = Number(response.headers.get('x-ratelimit-reset')); + if (response.headers.get('x-ratelimit-reset') != null && Number.isFinite(reset)) { + return Math.max(0, reset * 1000 - Date.now()); + } + return undefined; +} + /** * Reads pull requests through GitHub's REST API with a token the caller supplies. Only the * decision (absent, rate limited, failed) leaves this module; response bodies and error text @@ -191,12 +217,34 @@ export function createGitHubPullRequestSource({ throw new PullRequestSourceError('UPSTREAM_ERROR'); } } - if (isRateLimited(response)) throw new PullRequestSourceError('RATE_LIMITED'); + if (isRateLimited(response)) { + throw new PullRequestSourceError('RATE_LIMITED', retryAfterMs(response)); + } /** A repository the token cannot see is indistinguishable from one without pull requests. */ if (response.status === 404) return null; throw new PullRequestSourceError('UPSTREAM_ERROR'); } + /** Reads every page up to a bound, so a failure on a later page still shows. */ + async function readCheckRuns( + pathname: string, + token: string, + ): Promise<{ runs: GitHubCheckRun[]; incomplete: boolean }> { + const runs: GitHubCheckRun[] = []; + for (let page = 1; page <= MAX_CHECK_RUN_PAGES; page++) { + const body = await getJson(`${pathname}?per_page=${CHECK_RUN_PAGE_SIZE}&page=${page}`, token); + if (body == null) return { runs, incomplete: false }; + const parsed = parseCheckRuns(body); + runs.push(...parsed.runs); + const done = + parsed.total != null + ? runs.length >= parsed.total || parsed.runs.length === 0 + : parsed.runs.length < CHECK_RUN_PAGE_SIZE; + if (done) return { runs, incomplete: false }; + } + return { runs, incomplete: true }; + } + return { async find({ repo, branch, token }) { const match = REPO_PATTERN.exec(repo); @@ -205,22 +253,28 @@ export function createGitHubPullRequestSource({ if (!isPathSegment(owner) || !isPathSegment(name)) return null; const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; const head = encodeURIComponent(`${owner}:${branch}`); - const listed = await getJson( - `${base}/pulls?state=all&sort=updated&direction=desc&per_page=10&head=${head}`, - token, - ); - if (listed == null) return null; - if (!Array.isArray(listed)) throw new PullRequestSourceError('UPSTREAM_ERROR'); - const candidates = listed.map(parseListItem); - const chosen = candidates.find((item) => item.state === 'open') ?? candidates[0]; + /** `undefined`: the repository is not visible. `null`: it is, and nothing matched. */ + const listPulls = async (state: 'open' | 'closed', perPage: number) => { + const listed = await getJson( + `${base}/pulls?state=${state}&sort=updated&direction=desc&per_page=${perPage}&head=${head}`, + token, + ); + if (listed == null) return undefined; + if (!Array.isArray(listed)) throw new PullRequestSourceError('UPSTREAM_ERROR'); + return listed.map(parseListItem)[0] ?? null; + }; + /** Open first on its own, so closed history on a reused branch name cannot hide it. */ + const open = await listPulls('open', 10); + if (open === undefined) return null; + const chosen = open ?? (await listPulls('closed', 1)); if (chosen == null) return null; - const [pull, runs] = await Promise.all([ + const [pull, checks] = await Promise.all([ getJson(`${base}/pulls/${chosen.number}`, token), - getJson(`${base}/commits/${chosen.sha}/check-runs?per_page=100`, token), + readCheckRuns(`${base}/commits/${chosen.sha}/check-runs`, token), ]); if (pull == null) return null; - return toConversationPullRequest(parsePull(pull), runs == null ? [] : parseCheckRuns(runs)); + return toConversationPullRequest(parsePull(pull), checks.runs, checks.incomplete); }, }; } diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index 9f504a14096..abb61a56a3d 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -103,3 +103,106 @@ describe('createPullRequestLookup', () => { expect(find).toHaveBeenCalledTimes(4); }); }); + +describe('credential scoping', () => { + it('never serves a result fetched with one credential to another', async () => { + const find = jest.fn(async ({ token }: { token: string }) => + token === 'tenant-a' ? value : null, + ); + const lookup = createPullRequestLookup({ source: { find } }); + await expect(lookup({ ...input, token: 'tenant-a' })).resolves.toEqual({ ok: true, value }); + await expect(lookup({ ...input, token: 'tenant-b' })).resolves.toEqual({ + ok: true, + value: null, + }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('still reuses a result for the same credential', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, token: 'tenant-a' }); + await lookup({ ...input, token: 'tenant-a' }); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('does not share an in-flight request between credentials', async () => { + const releases: Array<() => void> = []; + const find = jest.fn( + ({ token }: { token: string }) => + new Promise((resolve) => { + releases.push(() => resolve(token === 'a' ? value : null)); + }), + ); + const lookup = createPullRequestLookup({ source: { find } }); + const a = lookup({ ...input, token: 'a' }); + const b = lookup({ ...input, token: 'b' }); + expect(find).toHaveBeenCalledTimes(2); + releases.forEach((release) => release()); + await expect(a).resolves.toEqual({ ok: true, value }); + await expect(b).resolves.toEqual({ ok: true, value: null }); + }); +}); + +describe('rate limit cooldown', () => { + const limited = (retryAfterMs?: number) => + new PullRequestSourceError('RATE_LIMITED', retryAfterMs); + const rateLimited = { ok: false, error: { code: 'RATE_LIMITED' } }; + + it('stops asking GitHub for every branch of the credential until the retry time', async () => { + let clock = 0; + const find = jest.fn().mockRejectedValueOnce(limited(60_000)).mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await expect(lookup(input)).resolves.toEqual(rateLimited); + clock = 30_000; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual(rateLimited); + expect(find).toHaveBeenCalledTimes(1); + clock = 60_001; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual({ ok: true, value }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('does not block a different credential', async () => { + const find = jest.fn().mockRejectedValueOnce(limited(60_000)).mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, token: 'a' }); + await expect(lookup({ ...input, token: 'b', branch: 'other' })).resolves.toEqual({ + ok: true, + value, + }); + }); + + it('waits at least a short floor when GitHub gives no retry time', async () => { + let clock = 0; + const find = jest.fn().mockRejectedValueOnce(limited()).mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await lookup(input); + clock = 9_999; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual(rateLimited); + clock = 10_001; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual({ ok: true, value }); + }); + + it('caps an absurd retry time so one bad header cannot silence the feature for a day', async () => { + let clock = 0; + const find = jest + .fn() + .mockRejectedValueOnce(limited(24 * 60 * 60_000)) + .mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await lookup(input); + clock = 10 * 60_000 + 1; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual({ ok: true, value }); + }); + + it('keeps serving a cached result while the credential is cooling down', async () => { + let clock = 0; + const find = jest.fn().mockResolvedValueOnce(value).mockRejectedValueOnce(limited(60_000)); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await lookup(input); + await lookup({ ...input, branch: 'other' }); + clock = 5_000; + await expect(lookup(input)).resolves.toEqual({ ok: true, value }); + expect(find).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index 884cbbe67cc..4d0c4959a72 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -1,18 +1,30 @@ +import { createHash } from 'node:crypto'; import { logger } from '@librechat/data-schemas'; import type { PullRequestLookup, PullRequestLookupResult, PullRequestSource } from './types'; import { PullRequestSourceError } from './types'; import { getSafeErrorMetadata } from '~/utils'; -/** A failed lookup is remembered briefly so a rate limit is not hammered by every poll. */ +/** A failed lookup is remembered briefly so a failing upstream is not hammered by every poll. */ const FAILURE_TTL_MS = 10_000; +/** Used when GitHub rate limits a credential without saying when to come back. */ +const MIN_COOLDOWN_MS = 10_000; +/** One bad header must not silence the feature for hours. */ +const MAX_COOLDOWN_MS = 10 * 60_000; const DEFAULT_MAX_ENTRIES = 500; type Entry = { result: PullRequestLookupResult; expiresAt: number }; +/** Keys carry a digest, never the token, so no credential is held in a map key or a heap dump. */ +const scopeOf = (token: string): string => + createHash('sha256').update(token).digest('hex').slice(0, 32); + /** - * Caches lookups per repository branch and shares one in-flight request between concurrent - * callers. A pull request is repository data, not user data: callers must have authorized the - * conversation before asking, and the cache is keyed only by what GitHub is asked about. + * Caches lookups per credential, repository and branch, and shares one in-flight request between + * concurrent callers with the same credential. What a token can see is part of the answer (a + * private repository is a pull request for one tenant and nothing for another), so the credential + * scopes both the cache and the in-flight map. A rate limit applies to the credential, not the + * branch, so it starts a cooldown for every branch under that credential. Callers must have + * authorized the conversation before asking. */ export function createPullRequestLookup({ source, @@ -25,6 +37,8 @@ export function createPullRequestLookup({ }): PullRequestLookup { const entries = new Map(); const inflight = new Map>(); + const cooldowns = new Map(); + const rateLimited: PullRequestLookupResult = { ok: false, error: { code: 'RATE_LIMITED' } }; function remember(key: string, result: PullRequestLookupResult, ttlMs: number): void { const lifetime = result.ok ? ttlMs : Math.min(ttlMs, FAILURE_TTL_MS); @@ -37,12 +51,26 @@ export function createPullRequestLookup({ } } + function startCooldown(scope: string, retryAfterMs?: number): void { + const wait = Math.min(MAX_COOLDOWN_MS, Math.max(MIN_COOLDOWN_MS, retryAfterMs ?? 0)); + cooldowns.set(scope, now() + wait); + for (const [other, until] of cooldowns) { + if (until <= now()) cooldowns.delete(other); + } + } + return async ({ repo, branch, token, ttlMs }) => { - const key = `${repo}#${branch}`; + const scope = scopeOf(token); + const key = `${scope}\0${repo}#${branch}`; const cached = entries.get(key); if (cached != null && cached.expiresAt > now()) return cached.result; const pending = inflight.get(key); if (pending != null) return pending; + const until = cooldowns.get(scope); + if (until != null) { + if (until > now()) return rateLimited; + cooldowns.delete(scope); + } const run = (async (): Promise => { let result: PullRequestLookupResult; @@ -53,6 +81,9 @@ export function createPullRequestLookup({ logger.warn('[PullRequests] Lookup failed', getSafeErrorMetadata(error)); } const code = error instanceof PullRequestSourceError ? error.code : 'UPSTREAM_ERROR'; + if (error instanceof PullRequestSourceError && error.code === 'RATE_LIMITED') { + startCooldown(scope, error.retryAfterMs); + } result = { ok: false, error: { code } }; } remember(key, result, ttlMs); diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts index 90026dd9d55..3a386d21a3f 100644 --- a/packages/api/src/pulls/types.ts +++ b/packages/api/src/pulls/types.ts @@ -10,7 +10,11 @@ export type PullRequestErrorCode = (typeof PULL_REQUEST_ERROR_CODES)[number]; /** Thrown by a source for an operational failure; the lookup translates it into a result. */ export class PullRequestSourceError extends Error { - constructor(public readonly code: PullRequestErrorCode) { + constructor( + public readonly code: PullRequestErrorCode, + /** For `RATE_LIMITED`: how long GitHub asked callers to wait, when it said. */ + public readonly retryAfterMs?: number, + ) { super(`Pull request lookup failed: ${code}`); this.name = 'PullRequestSourceError'; } From d831aa3b01c5999fa4c185a8a428869148d43f31 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:13:56 +0200 Subject: [PATCH 03/10] fix: Address Second Round Review Findings on Pull Request Lookup Clear the stored lane when the conversation moves to another workspace or leaves attached execution, so the previous workspace's pull request is never served. Fence the lane write on the time it was reported, so an older report from another replica cannot replace a newer one. Honor GitHub's reset window up to an hour instead of ten minutes, and expose the request timeout, whole lookup timeout and check run page limit as endpoints.agents.pullRequests settings with the previous values as defaults. --- librechat.example.yaml | 4 + packages/api/src/code/lane.spec.ts | 46 ++++++- packages/api/src/code/lane.ts | 25 ++-- packages/api/src/pulls/github.spec.ts | 79 ++++++++++++ packages/api/src/pulls/github.ts | 45 +++++-- packages/api/src/pulls/handler.spec.ts | 18 +++ packages/api/src/pulls/handler.ts | 5 + packages/api/src/pulls/lookup.spec.ts | 21 ++- packages/api/src/pulls/lookup.ts | 8 +- packages/api/src/pulls/types.ts | 11 ++ packages/data-provider/src/config.spec.ts | 26 +++- packages/data-provider/src/config.ts | 6 + .../src/methods/conversation.spec.ts | 121 ++++++++++++++++-- .../data-schemas/src/methods/conversation.ts | 38 ++++-- packages/data-schemas/src/schema/convo.ts | 2 + packages/data-schemas/src/types/convo.ts | 2 + 16 files changed, 407 insertions(+), 50 deletions(-) diff --git a/librechat.example.yaml b/librechat.example.yaml index 2e0a2a3cae9..366cdc5d6c6 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -932,6 +932,10 @@ endpoints: # token: "${GITHUB_PULL_REQUEST_TOKEN}" # # Seconds a looked-up pull request is reused before GitHub is asked again (5 to 3600). # cacheTtlSeconds: 30 + # # Bounds on one lookup. A repository with many check runs reads one page of 100 per request. + # requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60) + # lookupTimeoutSeconds: 30 # longest the whole lookup may take (1 to 120) + # maxCheckRunPages: 10 # pages read before checks are reported as running (1 to 50) # # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in. # # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools. # # (optional) Require user approval before matching tool calls. Disabled by default. diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index f6660423023..8c049a82c65 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -16,7 +16,12 @@ describe('createLaneGitRecorder', () => { const setConvoLaneGit = jest.fn().mockResolvedValue(true); const record = createLaneGitRecorder({ user: 'u1', conversationId: 'c1', setConvoLaneGit }); await expect(record?.(laneGit)).resolves.toBe(true); - expect(setConvoLaneGit).toHaveBeenCalledWith({ user: 'u1', conversationId: 'c1', laneGit }); + expect(setConvoLaneGit).toHaveBeenCalledWith({ + user: 'u1', + conversationId: 'c1', + laneGit, + reportedAt: expect.any(Date), + }); }); it.each([ @@ -43,6 +48,7 @@ describe('createLaneGitRecorder', () => { user: 'u1', conversationId: 'c1', laneGit, + reportedAt: expect.any(Date), repo: 'LibreChat-AI/LibreChat', }); }); @@ -62,6 +68,7 @@ describe('createLaneGitRecorder', () => { user: 'u1', conversationId: 'c1', laneGit, + reportedAt: expect.any(Date), }); }, ); @@ -210,6 +217,43 @@ describe('createLaneGitRecorder write order', () => { expect(events).toEqual(['start:a', 'start:b']); }); + it('stamps each report when it arrives, not when its write finally runs', async () => { + const stamps: Array<{ branch: string | null; at: number }> = []; + const releases: Array<() => void> = []; + const setConvoLaneGit = jest.fn( + ({ + laneGit: reported, + reportedAt, + }: { + laneGit: { branch: string | null }; + reportedAt: Date; + }) => + new Promise((resolve) => { + stamps.push({ branch: reported.branch, at: reportedAt.getTime() }); + releases.push(() => resolve(true)); + }), + ); + const times = [1_000, 2_000]; + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-7', + setConvoLaneGit, + now: () => new Date(times.shift() ?? 0), + }); + const first = record?.({ branch: 'a', head }); + const second = record?.({ branch: 'b', head }); + await flush(); + releases[0](); + await first; + await flush(); + releases[1](); + await second; + expect(stamps).toEqual([ + { branch: 'a', at: 1_000 }, + { branch: 'b', at: 2_000 }, + ]); + }); + it('still runs the next write after one fails', async () => { const { events, releases, fail, setConvoLaneGit } = controlledWriter(); const record = createLaneGitRecorder({ diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index d784dab35bd..37248e846cd 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -7,6 +7,8 @@ export type LaneGitWriter = (input: { conversationId: string; laneGit: WorkspaceLaneGit; repo?: string; + /** When the command settled; the database ignores a report older than the one it holds. */ + reportedAt: Date; }) => Promise; const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; @@ -19,11 +21,10 @@ const isSafeRepo = (repo: string | undefined): repo is string => repo.split('/').every((segment) => segment !== '.' && segment !== '..'); /** - * The tail of each conversation's pending writes. A later command's report must not be applied - * before an earlier one finishes: database completion order is not request order, so an - * unordered older write could land last and leave the conversation on a stale branch. Entries - * leave the map as soon as their chain drains, so it only holds conversations with a write in - * flight. + * The tail of each conversation's pending writes in this process. It keeps one process from + * racing its own writes; correctness across replicas comes from `reportedAt`, which the database + * fences on, so an older report is ignored wherever it lands. Entries leave the map as soon as + * their chain drains, so it only holds conversations with a write in flight. */ const pendingWrites = new Map>(); @@ -31,8 +32,9 @@ const pendingWrites = new Map>(); * Records the lane state a finished command reported, for the owner's conversation. It never * throws and is never awaited by the command: the branch is a header affordance, so a database * outage must not fail or delay the agent's tool call. Writes for one conversation run in the - * order they were reported. Resolves to whether the stored value changed, which is false for an - * unchanged lane and for a conversation not saved yet. A repo that is not a plain `owner/name` + * order they were reported, and each carries the time it was reported so the database can ignore + * a stale one. Resolves to whether the write applied, which is false when a newer report is + * already stored and for a conversation not saved yet. A repo that is not a plain `owner/name` * is dropped rather than stored. */ export function createLaneGitRecorder({ @@ -40,23 +42,26 @@ export function createLaneGitRecorder({ conversationId, repo, setConvoLaneGit, + now = () => new Date(), }: { user: string | undefined; conversationId: string | undefined; repo?: string; setConvoLaneGit: LaneGitWriter; + now?: () => Date; }): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { if (!user || !conversationId) return undefined; const safeRepo = isSafeRepo(repo) ? repo : undefined; const queueKey = `${user}\0${conversationId}`; - const write = async (laneGit: WorkspaceLaneGit): Promise => { + const write = async (laneGit: WorkspaceLaneGit, reportedAt: Date): Promise => { try { return await setConvoLaneGit({ user, conversationId, laneGit, ...(safeRepo ? { repo: safeRepo } : {}), + reportedAt, }); } catch (error) { logger.warn('[LaneGit] Failed to record lane state', getSafeErrorMetadata(error)); @@ -65,9 +70,11 @@ export function createLaneGitRecorder({ }; return (laneGit) => { + /** Stamped when the command settles, not when its write finally runs. */ + const reportedAt = now(); const previous = pendingWrites.get(queueKey) ?? Promise.resolve(); /** `write` never rejects, so a failed write cannot stall the writes queued behind it. */ - const next = previous.then(() => write(laneGit)); + const next = previous.then(() => write(laneGit, reportedAt)); pendingWrites.set(queueKey, next); void next.then(() => { if (pendingWrites.get(queueKey) === next) pendingWrites.delete(queueKey); diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index e7d0233693b..f9e5c752a46 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -273,3 +273,82 @@ describe('rate limit back-off hint', () => { expect(error.retryAfterMs).toBeUndefined(); }); }); + +describe('lookup bounds', () => { + const run = (conclusion: string | null, status = 'completed') => ({ status, conclusion }); + const everyRoute = (url: string, checks: () => Response) => { + if (url.includes('/pulls?state=open')) return json(listed()); + if (url.includes('/pulls/7')) return json(pull()); + if (url.includes('/check-runs')) return checks(); + throw new Error(`unexpected ${url}`); + }; + const aborts = (init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }); + + it('stops reading check runs at the configured page limit and calls the rollup incomplete', async () => { + const fetchFn = jest.fn(async (url: string) => + everyRoute(url, () => + json({ total_count: 6000, check_runs: Array.from({ length: 100 }, () => run('success')) }), + ), + ); + const source = createGitHubPullRequestSource({ fetchFn }); + const result = await source.find({ + repo: 'o/r', + branch: 'feat/x', + token: 't', + limits: { maxCheckRunPages: 2 }, + }); + expect(result).toMatchObject({ checks: 'running' }); + expect(fetchFn.mock.calls.filter(([url]) => String(url).includes('/check-runs'))).toHaveLength( + 2, + ); + }); + + it('gives up on one request that outlives the configured request timeout', async () => { + const fetchFn = jest.fn((_url: string, init?: RequestInit) => aborts(init)); + const source = createGitHubPullRequestSource({ fetchFn }); + const started = Date.now(); + await expect( + source.find({ + repo: 'o/r', + branch: 'feat/x', + token: 't', + limits: { requestTimeoutMs: 20, lookupTimeoutMs: 5_000 }, + }), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it('bounds the whole lookup, not only each request', async () => { + let calls = 0; + const fetchFn = jest.fn((url: string, init?: RequestInit) => { + calls += 1; + if (calls === 1 && url.includes('/pulls?state=open')) return Promise.resolve(json(listed())); + return aborts(init); + }); + const source = createGitHubPullRequestSource({ fetchFn }); + const started = Date.now(); + await expect( + source.find({ + repo: 'o/r', + branch: 'feat/x', + token: 't', + limits: { requestTimeoutMs: 10_000, lookupTimeoutMs: 30 }, + }), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("keeps today's defaults when no limits are given", async () => { + const seen: Array = []; + const fetchFn = jest.fn(async (url: string, init?: RequestInit) => { + seen.push(init?.signal); + return everyRoute(url, () => json({ total_count: 0, check_runs: [] })); + }); + const source = createGitHubPullRequestSource({ fetchFn }); + await expect(find(source)).resolves.toMatchObject({ number: 7 }); + expect(seen.every((signal) => signal != null && !signal.aborted)).toBe(true); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index 8f8709c037e..55d778efdcc 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -8,10 +8,11 @@ import type { PullRequestSource } from './types'; import { PullRequestSourceError } from './types'; const GITHUB_API_BASE = 'https://api.github.com'; -const REQUEST_TIMEOUT_MS = 10_000; +/** Defaults for the operator bounds in `endpoints.agents.pullRequests`. */ +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_LOOKUP_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_CHECK_RUN_PAGES = 10; const CHECK_RUN_PAGE_SIZE = 100; -/** Bounds the requests one lookup can make; past it the rollup is reported as still running. */ -const MAX_CHECK_RUN_PAGES = 10; const MAX_TITLE_LENGTH = 256; const REPO_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; const FAILING_CONCLUSIONS = new Set([ @@ -195,17 +196,26 @@ export function createGitHubPullRequestSource({ fetchFn = fetch, apiBase = GITHUB_API_BASE, }: { fetchFn?: PullRequestFetch; apiBase?: string } = {}): PullRequestSource { - async function getJson(pathname: string, token: string): Promise { + /** One lookup's credential and deadlines, shared by every request it makes. */ + type Lookup = { + token: string; + requestTimeoutMs: number; + /** Aborts when the whole lookup runs out of time. */ + deadline: AbortSignal; + maxCheckRunPages: number; + }; + + async function getJson(pathname: string, lookup: Lookup): Promise { let response: Response; try { response = await fetchFn(`${apiBase}${pathname}`, { headers: { Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${lookup.token}`, 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'LibreChat-Pull-Requests', }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.any([AbortSignal.timeout(lookup.requestTimeoutMs), lookup.deadline]), }); } catch { throw new PullRequestSourceError('UPSTREAM_ERROR'); @@ -228,11 +238,14 @@ export function createGitHubPullRequestSource({ /** Reads every page up to a bound, so a failure on a later page still shows. */ async function readCheckRuns( pathname: string, - token: string, + lookup: Lookup, ): Promise<{ runs: GitHubCheckRun[]; incomplete: boolean }> { const runs: GitHubCheckRun[] = []; - for (let page = 1; page <= MAX_CHECK_RUN_PAGES; page++) { - const body = await getJson(`${pathname}?per_page=${CHECK_RUN_PAGE_SIZE}&page=${page}`, token); + for (let page = 1; page <= lookup.maxCheckRunPages; page++) { + const body = await getJson( + `${pathname}?per_page=${CHECK_RUN_PAGE_SIZE}&page=${page}`, + lookup, + ); if (body == null) return { runs, incomplete: false }; const parsed = parseCheckRuns(body); runs.push(...parsed.runs); @@ -246,7 +259,13 @@ export function createGitHubPullRequestSource({ } return { - async find({ repo, branch, token }) { + async find({ repo, branch, token, limits }) { + const lookup: Lookup = { + token, + requestTimeoutMs: limits?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + deadline: AbortSignal.timeout(limits?.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS), + maxCheckRunPages: limits?.maxCheckRunPages ?? DEFAULT_MAX_CHECK_RUN_PAGES, + }; const match = REPO_PATTERN.exec(repo); if (match == null || branch.length === 0) return null; const [, owner, name] = match; @@ -257,7 +276,7 @@ export function createGitHubPullRequestSource({ const listPulls = async (state: 'open' | 'closed', perPage: number) => { const listed = await getJson( `${base}/pulls?state=${state}&sort=updated&direction=desc&per_page=${perPage}&head=${head}`, - token, + lookup, ); if (listed == null) return undefined; if (!Array.isArray(listed)) throw new PullRequestSourceError('UPSTREAM_ERROR'); @@ -270,8 +289,8 @@ export function createGitHubPullRequestSource({ if (chosen == null) return null; const [pull, checks] = await Promise.all([ - getJson(`${base}/pulls/${chosen.number}`, token), - readCheckRuns(`${base}/commits/${chosen.sha}/check-runs`, token), + getJson(`${base}/pulls/${chosen.number}`, lookup), + readCheckRuns(`${base}/commits/${chosen.sha}/check-runs`, lookup), ]); if (pull == null) return null; return toConversationPullRequest(parsePull(pull), checks.runs, checks.incomplete); diff --git a/packages/api/src/pulls/handler.spec.ts b/packages/api/src/pulls/handler.spec.ts index 7032920a91c..1e814ffbe8f 100644 --- a/packages/api/src/pulls/handler.spec.ts +++ b/packages/api/src/pulls/handler.spec.ts @@ -76,11 +76,29 @@ describe('createConversationPullRequestHandler', () => { branch: 'feat/x', token: 'ghp_secret', ttlMs: 30_000, + limits: { requestTimeoutMs: 10_000, lookupTimeoutMs: 30_000, maxCheckRunPages: 10 }, }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ pullRequest: pr }); }); + it('passes the configured lookup bounds to GitHub', async () => { + const { run, lookup } = setup({ + settings: { + ...enabled, + requestTimeoutSeconds: 3, + lookupTimeoutSeconds: 8, + maxCheckRunPages: 4, + }, + }); + await run(); + expect(lookup).toHaveBeenCalledWith( + expect.objectContaining({ + limits: { requestTimeoutMs: 3_000, lookupTimeoutMs: 8_000, maxCheckRunPages: 4 }, + }), + ); + }); + it('answers null without reading anything when the feature is off', async () => { for (const settings of [null, { enabled: false }]) { const { run, res, getConvoLaneGit } = setup({ settings }); diff --git a/packages/api/src/pulls/handler.ts b/packages/api/src/pulls/handler.ts index 2f2c5bb35d8..9e2b60be9cf 100644 --- a/packages/api/src/pulls/handler.ts +++ b/packages/api/src/pulls/handler.ts @@ -78,6 +78,11 @@ export function createConversationPullRequestHandler(deps: { branch: laneGit.branch, token, ttlMs: (settings.cacheTtlSeconds ?? 30) * 1000, + limits: { + requestTimeoutMs: (settings.requestTimeoutSeconds ?? 10) * 1000, + lookupTimeoutMs: (settings.lookupTimeoutSeconds ?? 30) * 1000, + maxCheckRunPages: settings.maxCheckRunPages ?? 10, + }, }); if (!result.ok) { res.status(503).json({ diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index abb61a56a3d..70ab39c5cc7 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -183,7 +183,22 @@ describe('rate limit cooldown', () => { await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual({ ok: true, value }); }); - it('caps an absurd retry time so one bad header cannot silence the feature for a day', async () => { + it('honors a reset GitHub reports well beyond ten minutes', async () => { + let clock = 0; + const find = jest + .fn() + .mockRejectedValueOnce(limited(40 * 60_000)) + .mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await lookup(input); + clock = 39 * 60_000; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual(rateLimited); + expect(find).toHaveBeenCalledTimes(1); + clock = 40 * 60_000 + 1; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual({ ok: true, value }); + }); + + it('caps a hint past the hour GitHub resets within, so one bad header cannot silence the feature for a day', async () => { let clock = 0; const find = jest .fn() @@ -191,7 +206,9 @@ describe('rate limit cooldown', () => { .mockResolvedValue(value); const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); await lookup(input); - clock = 10 * 60_000 + 1; + clock = 60 * 60_000 - 1; + await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual(rateLimited); + clock = 60 * 60_000 + 1; await expect(lookup({ ...input, branch: 'other' })).resolves.toEqual({ ok: true, value }); }); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index 4d0c4959a72..6bfec2e60af 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -8,8 +8,8 @@ import { getSafeErrorMetadata } from '~/utils'; const FAILURE_TTL_MS = 10_000; /** Used when GitHub rate limits a credential without saying when to come back. */ const MIN_COOLDOWN_MS = 10_000; -/** One bad header must not silence the feature for hours. */ -const MAX_COOLDOWN_MS = 10 * 60_000; +/** GitHub's primary limit resets within the hour; a larger hint is malformed and is capped. */ +const MAX_COOLDOWN_MS = 60 * 60_000; const DEFAULT_MAX_ENTRIES = 500; type Entry = { result: PullRequestLookupResult; expiresAt: number }; @@ -59,7 +59,7 @@ export function createPullRequestLookup({ } } - return async ({ repo, branch, token, ttlMs }) => { + return async ({ repo, branch, token, ttlMs, limits }) => { const scope = scopeOf(token); const key = `${scope}\0${repo}#${branch}`; const cached = entries.get(key); @@ -75,7 +75,7 @@ export function createPullRequestLookup({ const run = (async (): Promise => { let result: PullRequestLookupResult; try { - result = { ok: true, value: await source.find({ repo, branch, token }) }; + result = { ok: true, value: await source.find({ repo, branch, token, limits }) }; } catch (error) { if (!(error instanceof PullRequestSourceError)) { logger.warn('[PullRequests] Lookup failed', getSafeErrorMetadata(error)); diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts index 3a386d21a3f..4ea68a5f849 100644 --- a/packages/api/src/pulls/types.ts +++ b/packages/api/src/pulls/types.ts @@ -20,11 +20,22 @@ export class PullRequestSourceError extends Error { } } +/** Operator bounds on one lookup; each defaults to the value the feature shipped with. */ +export type PullRequestLookupLimits = { + /** Longest one GitHub request may take. */ + requestTimeoutMs?: number; + /** Longest the whole lookup, every request together, may take. */ + lookupTimeoutMs?: number; + /** Most pages of check runs read before the rollup is reported as still running. */ + maxCheckRunPages?: number; +}; + export type PullRequestFindInput = { /** `owner/name`. */ repo: string; branch: string; token: string; + limits?: PullRequestLookupLimits; }; /** Finds the pull request for a branch. Null is a documented absence, not a failure. */ diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 7123e83c4c4..dcead84ee7e 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -1125,7 +1125,13 @@ describe('agent pull request config', () => { version: '1.0', endpoints: { agents: { pullRequests: {} } }, }); - expect(empty.endpoints?.agents?.pullRequests).toEqual({ enabled: false, cacheTtlSeconds: 30 }); + expect(empty.endpoints?.agents?.pullRequests).toEqual({ + enabled: false, + cacheTtlSeconds: 30, + requestTimeoutSeconds: 10, + lookupTimeoutSeconds: 30, + maxCheckRunPages: 10, + }); }); it('accepts an enabled block with an environment variable reference', () => { @@ -1144,6 +1150,24 @@ describe('agent pull request config', () => { }, ); + it.each([ + ['requestTimeoutSeconds', 0], + ['requestTimeoutSeconds', 61], + ['lookupTimeoutSeconds', 0], + ['lookupTimeoutSeconds', 121], + ['maxCheckRunPages', 0], + ['maxCheckRunPages', 51], + ['maxCheckRunPages', 1.5], + ])('rejects %s of %s', (field, value) => { + expect(parse({ [field]: value }).success).toBe(false); + }); + + it('accepts the documented bounds', () => { + expect( + parse({ requestTimeoutSeconds: 60, lookupTimeoutSeconds: 120, maxCheckRunPages: 50 }).success, + ).toBe(true); + }); + it.each([4, 3601, 1.5])('rejects a cache lifetime of %s seconds', (cacheTtlSeconds) => { expect(parse({ cacheTtlSeconds }).success).toBe(false); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 38e7fdfb279..10b61ac82ca 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1853,6 +1853,12 @@ export const agentsEndpointSchema = baseEndpointSchema token: pullRequestTokenReferenceSchema.optional(), /** Seconds a looked-up pull request is reused before GitHub is asked again. */ cacheTtlSeconds: z.number().int().min(5).max(3600).optional().default(30), + /** Longest one GitHub request may take. Raise it behind a slow proxy. */ + requestTimeoutSeconds: z.number().int().min(1).max(60).optional().default(10), + /** Longest a whole lookup, every request together, may hold the header request. */ + lookupTimeoutSeconds: z.number().int().min(1).max(120).optional().default(30), + /** Pages of 100 check runs read before the rollup is reported as still running. */ + maxCheckRunPages: z.number().int().min(1).max(50).optional().default(10), }) .superRefine((value, ctx) => { if (value.enabled && !value.token) { diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index a48f2e4be91..49c0dcc1145 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -1679,6 +1679,63 @@ describe('Conversation Operations', () => { }); }; + describe('stale lane state', () => { + const lane = { branch: 'feat/old', head: 'a'.repeat(40), repo: 'o/old' }; + const stored = (conversationId: string) => + methods.getConvoCodeEnvironmentDecision('user123', conversationId); + + it('is cleared when the chat moves to another workspace', async () => { + const conversationId = await seedDecision({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [mac], + laneGit: lane, + }); + await expect(methods.getConvoLaneGit('user123', conversationId)).resolves.toEqual(lane); + await moveFromStored(conversationId, [team]); + await expect(methods.getConvoLaneGit('user123', conversationId)).resolves.toBeNull(); + }); + + it('is cleared when the chat leaves attached execution', async () => { + const conversationId = await seedDecision({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [mac], + laneGit: lane, + }); + const decision = await stored(conversationId); + await methods.replaceConvoCodeEnvironmentDecision({ + user: 'user123', + conversationId, + expected: { + codeEnvironmentMode: decision?.codeEnvironmentMode, + codeWorkspaces: decision?.codeWorkspaces, + codeEnvironmentRevision: decision?.codeEnvironmentRevision, + }, + codeEnvironmentMode: 'without_attached', + }); + await expect(methods.getConvoLaneGit('user123', conversationId)).resolves.toBeNull(); + }); + + it('is kept when the move is refused', async () => { + const conversationId = await seedDecision({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [mac], + laneGit: lane, + }); + await methods.replaceConvoCodeEnvironmentDecision({ + user: 'user123', + conversationId, + expected: { + codeEnvironmentMode: 'attached', + codeWorkspaces: [team], + codeEnvironmentRevision: 99, + }, + codeEnvironmentMode: 'attached', + codeWorkspaces: [vm], + }); + await expect(methods.getConvoLaneGit('user123', conversationId)).resolves.toEqual(lane); + }); + }); + it('replaces the decision it read without disturbing timestamps', async () => { const conversationId = await seedDecision({ codeEnvironmentMode: 'attached', @@ -9389,11 +9446,48 @@ describe('laneGit', () => { await expect(methods.getConvoLaneGit('intruder', conversationId)).resolves.toBeNull(); }); - it('reports no change when the same state is recorded again', async () => { + const at = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)); + + it('ignores a report older than the stored one, so a delayed write cannot win', async () => { + const conversationId = await seed(); + const user = 'lane-user'; + const newer = { branch: 'newer', head }; + const older = { branch: 'older', head: 'b'.repeat(40) }; + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit: newer, reportedAt: at(10) }), + ).resolves.toBe(true); + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit: older, reportedAt: at(5) }), + ).resolves.toBe(false); + await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(newer); + }); + + it('applies a report newer than the stored one, and keeps the newest of a repeated state', async () => { + const conversationId = await seed(); + const user = 'lane-user'; + const a = { branch: 'a', head }; + const b = { branch: 'b', head }; + await methods.setConvoLaneGit({ user, conversationId, laneGit: a, reportedAt: at(1) }); + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit: b, reportedAt: at(2) }), + ).resolves.toBe(true); + await methods.setConvoLaneGit({ user, conversationId, laneGit: a, reportedAt: at(3) }); + await expect( + methods.setConvoLaneGit({ user, conversationId, laneGit: b, reportedAt: at(2.5) }), + ).resolves.toBe(false); + await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(a); + }); + + it('does not expose the report time through the read', async () => { const conversationId = await seed(); - const input = { user: 'lane-user', conversationId, laneGit: { branch: 'main', head } }; - await expect(methods.setConvoLaneGit(input)).resolves.toBe(true); - await expect(methods.setConvoLaneGit(input)).resolves.toBe(false); + await methods.setConvoLaneGit({ + user: 'lane-user', + conversationId, + laneGit: { branch: 'main', head }, + reportedAt: at(1), + }); + const stored = await methods.getConvoLaneGit('lane-user', conversationId); + expect(stored).not.toHaveProperty('reportedAt'); }); it('replaces the stored state when the branch or head changes', async () => { @@ -9407,23 +9501,30 @@ describe('laneGit', () => { await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(next); }); - it('stores the repository with the lane and treats a repository change as a change', async () => { + it('stores the repository with the lane and replaces it when the repository changes', async () => { const conversationId = await seed(); const user = 'lane-user'; const laneGit = { branch: 'main', head }; await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/r' }), + methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/r', reportedAt: at(1) }), ).resolves.toBe(true); await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual({ ...laneGit, repo: 'o/r', }); await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/r' }), - ).resolves.toBe(false); - await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/other' }), + methods.setConvoLaneGit({ + user, + conversationId, + laneGit, + repo: 'o/other', + reportedAt: at(2), + }), ).resolves.toBe(true); + await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual({ + ...laneGit, + repo: 'o/other', + }); }); it('keeps a detached or empty lane distinct from an unknown one', async () => { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 989ea8f8a7f..b7c5b027b76 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -334,11 +334,13 @@ export interface ConversationMethods { conversationId: string; laneGit: Pick, 'branch' | 'head'>; repo?: string; + /** When the worker reported this state; defaults to now. */ + reportedAt?: Date; }): Promise; getConvoLaneGit( user: string, conversationId: string, - ): Promise | null>; + ): Promise, 'reportedAt'> | null>; addConvoToolApprovalAllows(input: { user: string; conversationId: string; @@ -3016,34 +3018,43 @@ export function createConversationMethods( } /** - * Record the branch and head a conversation's code lane last reported. Owner-scoped and - * conditional on a change, so a lane that keeps reporting the same state costs no write. - * Server-written only: generic saves and imports cannot set it. Resolves to whether the - * stored value changed; false means unchanged or no such conversation for this owner. + * Record the branch and head a conversation's code lane last reported. Owner-scoped and fenced + * by report time: the write matches only while the stored report is not newer than this one, so + * a delayed older report, from this process or another replica, can never replace a newer + * state. Server-written only: generic saves and imports cannot set it. Resolves to whether the + * write applied; false means a newer report is already stored or there is no such conversation + * for this owner. */ async function setConvoLaneGit({ user, conversationId, laneGit, repo, + reportedAt = new Date(), }: { user: string; conversationId: string; laneGit: Pick, 'branch' | 'head'>; repo?: string; + reportedAt?: Date; }): Promise { const Conversation = mongoose.models.Conversation as Model; const next = { branch: laneGit.branch, head: laneGit.head, ...(repo ? { repo } : {}), + reportedAt, }; const result = await withoutMeiliIndexing( Conversation.updateOne( { user, conversationId, - $expr: { $ne: [{ $ifNull: ['$laneGit', null] }, { $literal: next }] }, + $or: [ + { 'laneGit.reportedAt': { $exists: false } }, + { 'laneGit.reportedAt': null }, + { 'laneGit.reportedAt': { $lte: reportedAt } }, + ], }, { $set: { laneGit: next } }, { timestamps: false }, @@ -3056,12 +3067,14 @@ export function createConversationMethods( async function getConvoLaneGit( user: string, conversationId: string, - ): Promise | null> { + ): Promise, 'reportedAt'> | null> { const Conversation = mongoose.models.Conversation as Model; const stored = await Conversation.findOne({ user, conversationId }) .select('laneGit') .lean | null>(); - return stored?.laneGit ?? null; + if (stored?.laneGit == null) return null; + const { branch, head, repo } = stored.laneGit; + return { branch, head, ...(repo ? { repo } : {}) }; } async function readAdmittedConvoCodeEnvironmentDecision(user: string, conversationId: string) { @@ -3115,11 +3128,16 @@ export function createConversationMethods( codeWorkspaces: expected.codeWorkspaces ?? { $in: [null] }, codeEnvironmentRevision: expected.codeEnvironmentRevision ?? { $in: [null] }, }, + /** The reported lane belongs to the workspace being replaced, so it goes with it. */ codeEnvironmentMode === 'attached' - ? { $set: { codeEnvironmentMode, codeWorkspaces }, $inc: { codeEnvironmentRevision: 1 } } + ? { + $set: { codeEnvironmentMode, codeWorkspaces }, + $unset: { laneGit: 1 }, + $inc: { codeEnvironmentRevision: 1 }, + } : { $set: { codeEnvironmentMode }, - $unset: { codeWorkspaces: 1 }, + $unset: { codeWorkspaces: 1, laneGit: 1 }, $inc: { codeEnvironmentRevision: 1 }, }, { new: true, timestamps: false }, diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index 150dce16ff9..1f7872ca577 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -54,6 +54,8 @@ const convoSchema: Schema = new Schema( branch: { type: String, default: null }, head: { type: String, default: null }, repo: { type: String, default: undefined }, + /** When the worker reported this state; fences out an older report that lands later. */ + reportedAt: { type: Date, default: undefined }, }, _id: false, default: undefined, diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index e610c967377..8cbeb3e375c 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -27,6 +27,8 @@ export interface IConversationLaneGit { head: string | null; /** `owner/name` of the lane's repository, as its worker registered it. */ repo?: string; + /** When the state was reported; internal, never returned by `getConvoLaneGit`. */ + reportedAt?: Date; } export interface ISubagentThreadLease { From c305a0c63314973b9a3476a38794e258cda42b2c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:36:05 +0200 Subject: [PATCH 04/10] fix: Authorize Pull Request Repositories and Fence Lane Writes Require an administrator allowlist of repositories before the server token is used, since a worker reports its own repository. Match a pull request to the commit the conversation last ran at, so a reused branch name does not return another pull request. Fence the lane write on the workspace the command ran in, so a write queued before a move cannot bring the old lane back, and record a subagent thread's lane on the visible root conversation. Key the lookup cache and shared request by the recorded commit and the lookup policy, and document the worker flag in the example config. --- api/server/services/ToolService.js | 12 +- .../services/__tests__/ToolService.spec.js | 16 ++- librechat.example.yaml | 7 +- packages/api/src/code/lane.spec.ts | 111 ++++++++++++++++++ packages/api/src/code/lane.ts | 47 +++++++- packages/api/src/pulls/github.spec.ts | 90 ++++++++++++++ packages/api/src/pulls/github.ts | 48 ++++++-- packages/api/src/pulls/handler.spec.ts | 58 ++++++++- packages/api/src/pulls/handler.ts | 18 +++ packages/api/src/pulls/lookup.spec.ts | 53 +++++++++ packages/api/src/pulls/lookup.ts | 17 ++- packages/api/src/pulls/types.ts | 2 + packages/data-provider/src/config.spec.ts | 31 ++++- packages/data-provider/src/config.ts | 22 ++++ .../src/methods/conversation.spec.ts | 86 ++++++++++++++ .../data-schemas/src/methods/conversation.ts | 56 +++++++-- 16 files changed, 641 insertions(+), 33 deletions(-) diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 2ca1500f82d..eae450be2cc 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -131,7 +131,12 @@ const { createOpenIDSessionTokenProvider } = require('~/server/services/OpenIDSe const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); -const { findPluginAuthsByKeys, getRoleByName, setConvoLaneGit } = require('~/models'); +const { + findPluginAuthsByKeys, + getRoleByName, + setConvoLaneGit, + getConvoOwnership, +} = require('~/models'); const { getFlowStateManager, getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); @@ -2391,6 +2396,11 @@ async function loadToolsForExecution({ user: req.user.id, conversationId: conversationId ?? runtimeRequestBody?.conversationId, repo: codeExecutionContext.codeWorkspace.environment?.repo, + workspace: { + environmentId: codeExecutionContext.codeWorkspace.environmentId, + workspaceId: codeExecutionContext.codeWorkspace.workspaceId, + }, + getConvoOwnership, setConvoLaneGit, }), linkedWorktrees: codeExecutionContext.codeWorkspace.linkedWorktrees, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 4f986aeac73..7a0a90b8303 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -25,6 +25,7 @@ const mockCreateAttachedWorkspaceBashTool = jest.fn(() => ({ name: AgentConstant const mockLaneGitRecorder = jest.fn(); const mockCreateLaneGitRecorder = jest.fn(() => mockLaneGitRecorder); const mockSetConvoLaneGit = jest.fn(); +const mockGetConvoOwnership = jest.fn(); const attachedWorkspaceOperations = [ 'read_file', 'search_text', @@ -176,6 +177,7 @@ jest.mock('~/models', () => ({ findPluginAuthsByKeys: jest.fn(), getRoleByName: (...args) => mockGetRoleByName(...args), setConvoLaneGit: (...args) => mockSetConvoLaneGit(...args), + getConvoOwnership: (...args) => mockGetConvoOwnership(...args), })); jest.mock('~/config', () => ({ getFlowStateManager: jest.fn(() => mockFlowManager), @@ -3287,11 +3289,15 @@ describe('ToolService - Action Capability Gating', () => { actionsEnabled: false, }); - expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith({ - user: req.user.id, - conversationId: 'resolved-convo', - setConvoLaneGit: expect.any(Function), - }); + expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith( + expect.objectContaining({ + user: req.user.id, + conversationId: 'resolved-convo', + workspace: { environmentId: 'personal-machine', workspaceId: 'project-a' }, + getConvoOwnership: expect.any(Function), + setConvoLaneGit: expect.any(Function), + }), + ); const { setConvoLaneGit } = mockCreateLaneGitRecorder.mock.calls[0][0]; const input = { user: 'u', conversationId: 'c', laneGit: { branch: 'main', head: null } }; await setConvoLaneGit(input); diff --git a/librechat.example.yaml b/librechat.example.yaml index 366cdc5d6c6..1955123195f 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -923,13 +923,18 @@ endpoints: # # Allow owners to cooperatively cancel ordinary background tools, including BYOM Bash. # ordinaryToolCancellation: false # # (optional) Show a conversation's pull request in the chat header. Off by default. It needs - # # a code worker that reports its branch, and a GitHub token held on the server. Use a + # # a code worker built from LibreChat-AI/code-interpreter#311 or later that reports its + # # branch, with CODEAPI_BRIDGE_LANE_GIT=true set on the Code API; without both, every chat + # # simply shows no pull request. It also needs a GitHub token held on the server. Use a # # fine-grained token limited to the repositories involved, with read-only "Pull requests" # # and "Checks" permissions: without "Checks" every matched pull request fails to load. # pullRequests: # enabled: false # # Environment variable reference only, never the token itself. # token: "${GITHUB_PULL_REQUEST_TOKEN}" + # # Repositories the token may be used for, as owner/name or owner/*. Required: a worker + # # reports its own repository, and only these are ever looked up with the server's token. + # allowedRepositories: ["LibreChat-AI/LibreChat"] # # Seconds a looked-up pull request is reused before GitHub is asked again (5 to 3600). # cacheTtlSeconds: 30 # # Bounds on one lookup. A repository with many check runs reads one page of 100 per request. diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index 8c049a82c65..b31929ec7ed 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -272,3 +272,114 @@ describe('createLaneGitRecorder write order', () => { await expect(second).resolves.toBe(true); }); }); + +describe('createLaneGitRecorder target conversation', () => { + const workspace = { environmentId: 'code-mac', workspaceId: 'primary' }; + const written = (setConvoLaneGit: jest.Mock) => setConvoLaneGit.mock.calls.map(([call]) => call); + + it('passes the workspace it ran in, so a stale writer can be fenced', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'target-1', + workspace, + setConvoLaneGit, + }); + await record?.(laneGit); + expect(written(setConvoLaneGit)[0]).toMatchObject({ + conversationId: 'target-1', + workspace: { ...workspace }, + }); + expect(written(setConvoLaneGit)[0].workspace.required).not.toBe(true); + }); + + it('records a subagent thread on the visible conversation, and requires its workspace to match', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const getConvoOwnership = jest.fn().mockResolvedValue({ + subagentThread: { rootConversationId: 'visible-root', parentConversationId: 'mid' }, + }); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'child-thread', + workspace, + getConvoOwnership, + setConvoLaneGit, + }); + await record?.(laneGit); + expect(getConvoOwnership).toHaveBeenCalledWith('u1', 'child-thread'); + expect(written(setConvoLaneGit)[0]).toMatchObject({ + conversationId: 'visible-root', + workspace: { ...workspace, required: true }, + }); + }); + + it('records an ordinary conversation on itself', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const getConvoOwnership = jest.fn().mockResolvedValue({ user: 'u1' }); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'plain', + workspace, + getConvoOwnership, + setConvoLaneGit, + }); + await record?.(laneGit); + expect(written(setConvoLaneGit)[0].conversationId).toBe('plain'); + }); + + it('looks the conversation up once however many reports follow', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const getConvoOwnership = jest.fn().mockResolvedValue({ + subagentThread: { rootConversationId: 'visible-root' }, + }); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'child-2', + workspace, + getConvoOwnership, + setConvoLaneGit, + }); + await record?.({ branch: 'a', head }); + await record?.({ branch: 'b', head }); + expect(getConvoOwnership).toHaveBeenCalledTimes(1); + expect(written(setConvoLaneGit).map((call) => call.conversationId)).toEqual([ + 'visible-root', + 'visible-root', + ]); + }); + + it('skips a report it cannot place rather than guess, and tries again on the next', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const getConvoOwnership = jest + .fn() + .mockRejectedValueOnce(new Error('mongodb://user:secret@host')) + .mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' } }); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'child-3', + workspace, + getConvoOwnership, + setConvoLaneGit, + }); + await expect(record?.({ branch: 'a', head })).resolves.toBe(false); + expect(setConvoLaneGit).not.toHaveBeenCalled(); + expect(JSON.stringify((logger.warn as jest.Mock).mock.calls)).not.toContain('secret'); + await expect(record?.({ branch: 'b', head })).resolves.toBe(true); + expect(written(setConvoLaneGit)[0].conversationId).toBe('visible-root'); + }); + + it('does not write a subagent lane to the parent without a workspace to verify it against', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const getConvoOwnership = jest.fn().mockResolvedValue({ + subagentThread: { rootConversationId: 'visible-root' }, + }); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'child-4', + getConvoOwnership, + setConvoLaneGit, + }); + await expect(record?.(laneGit)).resolves.toBe(false); + expect(setConvoLaneGit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index 37248e846cd..cc4a09c4dcc 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -2,6 +2,9 @@ import { logger } from '@librechat/data-schemas'; import type { WorkspaceLaneGit } from './workspace'; import { getSafeErrorMetadata } from '~/utils'; +/** The workspace a command ran in. `required` demands the conversation have it recorded. */ +export type LaneWorkspace = { environmentId: string; workspaceId: string; required?: boolean }; + export type LaneGitWriter = (input: { user: string; conversationId: string; @@ -9,8 +12,16 @@ export type LaneGitWriter = (input: { repo?: string; /** When the command settled; the database ignores a report older than the one it holds. */ reportedAt: Date; + /** The database ignores the report once the conversation is no longer on this workspace. */ + workspace?: LaneWorkspace; }) => Promise; +/** Reads what the recorder needs to place a conversation: whether it is a subagent thread. */ +export type LaneOwnershipReader = ( + user: string, + conversationId: string, +) => Promise<{ subagentThread?: { rootConversationId?: string | null } | null } | null | undefined>; + const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const MAX_REPO_LENGTH = 256; @@ -41,12 +52,16 @@ export function createLaneGitRecorder({ user, conversationId, repo, + workspace, + getConvoOwnership, setConvoLaneGit, now = () => new Date(), }: { user: string | undefined; conversationId: string | undefined; repo?: string; + workspace?: { environmentId: string; workspaceId: string }; + getConvoOwnership?: LaneOwnershipReader; setConvoLaneGit: LaneGitWriter; now?: () => Date; }): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { @@ -54,14 +69,44 @@ export function createLaneGitRecorder({ const safeRepo = isSafeRepo(repo) ? repo : undefined; const queueKey = `${user}\0${conversationId}`; + /** + * A subagent thread is a hidden child of the conversation the user sees, and its commands run on + * the parent's machine, so its lane belongs to the visible root. That write must also match the + * root's recorded workspace, since nothing else ties the child to it. The lookup is made once; + * a failed one is retried on the next report. + */ + let target: Promise<{ conversationId: string; required: boolean }> | undefined; + const resolveTarget = (): Promise<{ conversationId: string; required: boolean }> => { + if (target == null) { + const resolving = (async () => { + if (getConvoOwnership == null) return { conversationId, required: false }; + const owned = await getConvoOwnership(user, conversationId); + const root = owned?.subagentThread?.rootConversationId; + return root + ? { conversationId: root, required: true } + : { conversationId, required: false }; + })(); + target = resolving; + resolving.catch(() => { + if (target === resolving) target = undefined; + }); + } + return target; + }; + const write = async (laneGit: WorkspaceLaneGit, reportedAt: Date): Promise => { try { + const placed = await resolveTarget(); + if (placed.required && workspace == null) return false; return await setConvoLaneGit({ user, - conversationId, + conversationId: placed.conversationId, laneGit, ...(safeRepo ? { repo: safeRepo } : {}), reportedAt, + ...(workspace + ? { workspace: { ...workspace, ...(placed.required ? { required: true } : {}) } } + : {}), }); } catch (error) { logger.warn('[LaneGit] Failed to record lane state', getSafeErrorMetadata(error)); diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index f9e5c752a46..6d75c53f3f3 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -352,3 +352,93 @@ describe('lookup bounds', () => { expect(seen.every((signal) => signal != null && !signal.aborted)).toBe(true); }); }); + +describe('matching the recorded head', () => { + const recorded = 'c'.repeat(40); + const compare = (status: string) => () => json({ status }); + const findWith = ( + source: ReturnType, + head?: string | null, + ) => source.find({ repo: 'o/r', branch: 'feat/x', head, token: 't' }); + + const routes = (status: string) => ({ + '/pulls?state=open': () => json(listed()), + '/compare/': compare(status), + '/pulls/7': () => json(pull()), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + + it('accepts the pull request whose head is the recorded commit, without a comparison', async () => { + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json([{ number: 7, state: 'open', head: { sha: recorded } }]), + '/pulls/7': () => json(pull()), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + await expect(findWith(source, recorded)).resolves.toMatchObject({ number: 7 }); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); + }); + + it.each(['ahead', 'behind', 'identical'])( + 'accepts a pull request on the same line of commits (%s)', + async (status) => { + const { source } = sourceFor(routes(status)); + await expect(findWith(source, recorded)).resolves.toMatchObject({ number: 7 }); + }, + ); + + it('rejects a pull request whose commits diverged from the recorded head', async () => { + const { source } = sourceFor({ ...routes('diverged'), '/pulls?state=closed': () => json([]) }); + await expect(findWith(source, recorded)).resolves.toBeNull(); + }); + + it('treats a recorded commit GitHub no longer knows as no match', async () => { + const { source } = sourceFor({ + ...routes('ahead'), + '/compare/': () => new Response('', { status: 404 }), + '/pulls?state=closed': () => json([]), + }); + await expect(findWith(source, recorded)).resolves.toBeNull(); + }); + + it('falls back to a closed pull request that matches when the open one does not', async () => { + const { source } = sourceFor({ + '/pulls?state=open': () => json([{ number: 9, state: 'open', head: { sha } }]), + '/pulls?state=closed': () => json([{ number: 7, state: 'closed', head: { sha: recorded } }]), + '/compare/': compare('diverged'), + '/pulls/7': () => json(pull({ state: 'closed', merged: true })), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + await expect(findWith(source, recorded)).resolves.toMatchObject({ + number: 7, + state: 'merged', + }); + }); + + it('limits how many candidates it compares', async () => { + const many = Array.from({ length: 10 }, (_, index) => ({ + number: index + 1, + state: 'open', + head: { sha }, + })); + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json(many), + '/pulls?state=closed': () => json(many), + '/compare/': compare('diverged'), + }); + await expect(findWith(source, recorded)).resolves.toBeNull(); + const compares = fetchFn.mock.calls.filter(([url]) => String(url).includes('/compare/')); + expect(compares.length).toBeLessThanOrEqual(3); + }); + + it.each([null, undefined])('does not compare when no head was recorded (%s)', async (head) => { + const { source, fetchFn } = sourceFor(routes('diverged')); + await expect(findWith(source, head)).resolves.toMatchObject({ number: 7 }); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); + }); + + it('ignores a recorded head that is not a commit id rather than putting it in a path', async () => { + const { source, fetchFn } = sourceFor(routes('diverged')); + await expect(findWith(source, '../../etc')).resolves.toMatchObject({ number: 7 }); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index 55d778efdcc..35dcfb8a67f 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -13,6 +13,11 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const DEFAULT_LOOKUP_TIMEOUT_MS = 30_000; const DEFAULT_MAX_CHECK_RUN_PAGES = 10; const CHECK_RUN_PAGE_SIZE = 100; +/** Bounds the extra requests spent matching a pull request to the recorded commit. */ +const MAX_HEAD_COMPARISONS = 3; +/** Comparison results meaning the pull request and the recorded commit share a line of history. */ +const SAME_LINE_STATUSES = new Set(['ahead', 'behind', 'identical']); +const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; const MAX_TITLE_LENGTH = 256; const REPO_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; const FAILING_CONCLUSIONS = new Set([ @@ -86,7 +91,9 @@ function parsePull(value: unknown): GitHubPull { }; } -function parseListItem(value: unknown): { number: number; state: string; sha: string } { +type ListItem = { number: number; state: string; sha: string }; + +function parseListItem(value: unknown): ListItem { if ( !isRecord(value) || !isCount(value.number) || @@ -259,7 +266,7 @@ export function createGitHubPullRequestSource({ } return { - async find({ repo, branch, token, limits }) { + async find({ repo, branch, head: recordedHead, token, limits }) { const lookup: Lookup = { token, requestTimeoutMs: limits?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, @@ -271,21 +278,48 @@ export function createGitHubPullRequestSource({ const [, owner, name] = match; if (!isPathSegment(owner) || !isPathSegment(name)) return null; const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; - const head = encodeURIComponent(`${owner}:${branch}`); - /** `undefined`: the repository is not visible. `null`: it is, and nothing matched. */ + const headFilter = encodeURIComponent(`${owner}:${branch}`); + /** `undefined`: the repository is not visible. An empty list: it is, and nothing matched. */ const listPulls = async (state: 'open' | 'closed', perPage: number) => { const listed = await getJson( - `${base}/pulls?state=${state}&sort=updated&direction=desc&per_page=${perPage}&head=${head}`, + `${base}/pulls?state=${state}&sort=updated&direction=desc&per_page=${perPage}&head=${headFilter}`, lookup, ); if (listed == null) return undefined; if (!Array.isArray(listed)) throw new PullRequestSourceError('UPSTREAM_ERROR'); - return listed.map(parseListItem)[0] ?? null; + return listed.map(parseListItem); + }; + const recorded = + typeof recordedHead === 'string' && COMMIT_ID.test(recordedHead) ? recordedHead : undefined; + let comparisons = 0; + /** + * A branch name can be deleted and reused, so with a recorded commit a pull request counts + * only when it carries that commit or builds on it. A commit GitHub no longer knows is no + * match, and the number of comparisons per lookup is bounded. + */ + const matches = async (candidate: ListItem): Promise => { + if (recorded == null || candidate.sha === recorded) return true; + if (comparisons >= MAX_HEAD_COMPARISONS) return false; + comparisons += 1; + const comparison = await getJson(`${base}/compare/${recorded}...${candidate.sha}`, lookup); + if (comparison == null) return false; + if (!isRecord(comparison) || typeof comparison.status !== 'string') { + throw new PullRequestSourceError('UPSTREAM_ERROR'); + } + return SAME_LINE_STATUSES.has(comparison.status); + }; + const choose = async (candidates: ListItem[]): Promise => { + for (const candidate of candidates) { + if (await matches(candidate)) return candidate; + } + return null; }; /** Open first on its own, so closed history on a reused branch name cannot hide it. */ const open = await listPulls('open', 10); if (open === undefined) return null; - const chosen = open ?? (await listPulls('closed', 1)); + const chosen = + (await choose(open)) ?? + (await choose((await listPulls('closed', recorded == null ? 1 : 10)) ?? [])); if (chosen == null) return null; const [pull, checks] = await Promise.all([ diff --git a/packages/api/src/pulls/handler.spec.ts b/packages/api/src/pulls/handler.spec.ts index 1e814ffbe8f..a987e3bb499 100644 --- a/packages/api/src/pulls/handler.spec.ts +++ b/packages/api/src/pulls/handler.spec.ts @@ -20,7 +20,12 @@ const pr: TConversationPullRequest = { checks: 'passing', }; -const enabled = { enabled: true, token: '${GH_TOKEN}', cacheTtlSeconds: 30 }; +const enabled = { + enabled: true, + token: '${GH_TOKEN}', + cacheTtlSeconds: 30, + allowedRepositories: ['o/r'], +}; const configWith = (pullRequests?: Record) => ({ endpoints: { agents: { pullRequests } }, }); @@ -75,6 +80,7 @@ describe('createConversationPullRequestHandler', () => { repo: 'o/r', branch: 'feat/x', token: 'ghp_secret', + head: null, ttlMs: 30_000, limits: { requestTimeoutMs: 10_000, lookupTimeoutMs: 30_000, maxCheckRunPages: 10 }, }); @@ -99,6 +105,56 @@ describe('createConversationPullRequestHandler', () => { ); }); + it('passes the recorded head so the pull request can be matched to what the chat ran', async () => { + const head = 'a'.repeat(40); + const { run, lookup } = setup({ laneGit: { branch: 'feat/x', head, repo: 'o/r' } }); + await run(); + expect(lookup).toHaveBeenCalledWith(expect.objectContaining({ head })); + }); + + describe('repository allowlist', () => { + const lane = (repo: string) => ({ branch: 'feat/x', head: null, repo }); + + it.each([ + ['an exact match', ['o/r'], 'o/r'], + ['a different case', ['O/R'], 'o/r'], + ['an owner wildcard', ['o/*'], 'o/r'], + ['one of several', ['x/y', 'o/r'], 'o/r'], + ])('looks up a repository allowed by %s', async (_label, allowedRepositories, repo) => { + const { run, lookup } = setup({ + settings: { ...enabled, allowedRepositories }, + laneGit: lane(repo), + }); + await run(); + expect(lookup).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['another repository', ['o/r'], 'o/other'], + ['another owner', ['o/*'], 'x/r'], + ['a prefix of an allowed owner', ['org/*'], 'organization/r'], + ['an empty allowlist', [], 'o/r'], + ])('answers null without using the token for %s', async (_label, allowedRepositories, repo) => { + const { run, res, lookup } = setup({ + settings: { ...enabled, allowedRepositories }, + laneGit: lane(repo), + }); + await run(); + expect(lookup).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ pullRequest: null }); + }); + + it('does not reveal whether a disallowed repository exists', async () => { + const allowed = setup({ laneGit: lane('o/r') }); + const denied = setup({ laneGit: lane('secret/private') }); + await denied.run(); + await allowed.run(); + expect(denied.res.json).toHaveBeenCalledWith({ pullRequest: null }); + expect(JSON.stringify(denied.res.json.mock.calls)).not.toContain('secret'); + }); + }); + it('answers null without reading anything when the feature is off', async () => { for (const settings of [null, { enabled: false }]) { const { run, res, getConvoLaneGit } = setup({ settings }); diff --git a/packages/api/src/pulls/handler.ts b/packages/api/src/pulls/handler.ts index 9e2b60be9cf..b40d96fe11d 100644 --- a/packages/api/src/pulls/handler.ts +++ b/packages/api/src/pulls/handler.ts @@ -24,6 +24,15 @@ export function resolveTokenReference( return env[name]?.trim() || null; } +/** `owner/name` or `owner/*`, compared without case as GitHub does. Nothing else matches. */ +export function isAllowedRepository(repo: string, allowed: readonly string[] | undefined): boolean { + const [owner, name] = repo.toLowerCase().split('/'); + return (allowed ?? []).some((entry) => { + const [allowedOwner, allowedName] = entry.toLowerCase().split('/'); + return allowedOwner === owner && (allowedName === '*' || allowedName === name); + }); +} + const validConversationId = (value: string | undefined): value is string => value != null && value.trim() !== '' && value.length <= MAX_CONVERSATION_ID_LENGTH; @@ -66,6 +75,14 @@ export function createConversationPullRequestHandler(deps: { return; } + /** The repository comes from the worker, so it is never used with the token unless the + * administrator named it. A repository that is not allowed looks like one without a pull + * request. */ + if (!isAllowedRepository(laneGit.repo, settings.allowedRepositories)) { + res.status(200).json(NONE); + return; + } + const token = resolveTokenReference(settings.token, deps.env); if (token == null) { logger.warn('[PullRequests] Enabled without a usable token reference'); @@ -76,6 +93,7 @@ export function createConversationPullRequestHandler(deps: { const result = await deps.lookup({ repo: laneGit.repo, branch: laneGit.branch, + head: laneGit.head, token, ttlMs: (settings.cacheTtlSeconds ?? 30) * 1000, limits: { diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index 70ab39c5cc7..cf44dacf7f2 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -223,3 +223,56 @@ describe('rate limit cooldown', () => { expect(find).toHaveBeenCalledTimes(2); }); }); + +describe('lookup policy scoping', () => { + it('does not serve a result read under a tighter page limit to a caller allowing more', async () => { + const incomplete = { ...value, checks: 'running' as const }; + const find = jest.fn(async ({ limits }: { limits?: { maxCheckRunPages?: number } }) => + limits?.maxCheckRunPages === 1 ? incomplete : { ...value, checks: 'failing' as const }, + ); + const lookup = createPullRequestLookup({ source: { find } }); + await expect(lookup({ ...input, limits: { maxCheckRunPages: 1 } })).resolves.toEqual({ + ok: true, + value: incomplete, + }); + await expect(lookup({ ...input, limits: { maxCheckRunPages: 50 } })).resolves.toMatchObject({ + value: { checks: 'failing' }, + }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('honors the shorter freshness of a caller instead of the longer one another set', async () => { + let clock = 0; + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find }, now: () => clock }); + await lookup({ ...input, ttlMs: 3_600_000 }); + clock = 6_000; + await lookup({ ...input, ttlMs: 5_000 }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('does not share an in-flight request between different policies', async () => { + const find = jest.fn(() => new Promise(() => undefined)); + const lookup = createPullRequestLookup({ source: { find } }); + void lookup({ ...input, limits: { maxCheckRunPages: 1 } }); + void lookup({ ...input, limits: { maxCheckRunPages: 2 } }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('keys by the recorded head, so a reused branch name does not share a result', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, head: 'a'.repeat(40) }); + await lookup({ ...input, head: 'b'.repeat(40) }); + await lookup({ ...input, head: 'a'.repeat(40) }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('treats an unchanged policy as the same entry', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, limits: { maxCheckRunPages: 3, requestTimeoutMs: 5_000 } }); + await lookup({ ...input, limits: { requestTimeoutMs: 5_000, maxCheckRunPages: 3 } }); + expect(find).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index 6bfec2e60af..77d5ce3566c 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -19,8 +19,8 @@ const scopeOf = (token: string): string => createHash('sha256').update(token).digest('hex').slice(0, 32); /** - * Caches lookups per credential, repository and branch, and shares one in-flight request between - * concurrent callers with the same credential. What a token can see is part of the answer (a + * Caches lookups per credential, repository, branch, recorded commit and lookup policy, and shares + * one in-flight request between concurrent callers with all of those in common. What a token can see is part of the answer (a * private repository is a pull request for one tenant and nothing for another), so the credential * scopes both the cache and the in-flight map. A rate limit applies to the credential, not the * branch, so it starts a cooldown for every branch under that credential. Callers must have @@ -59,9 +59,16 @@ export function createPullRequestLookup({ } } - return async ({ repo, branch, token, ttlMs, limits }) => { + return async ({ repo, branch, head, token, ttlMs, limits }) => { const scope = scopeOf(token); - const key = `${scope}\0${repo}#${branch}`; + /** Everything that changes the answer or how long it may be reused is part of the key. */ + const policy = [ + ttlMs, + limits?.requestTimeoutMs, + limits?.lookupTimeoutMs, + limits?.maxCheckRunPages, + ].join(','); + const key = `${scope}\0${repo}#${branch}\0${head ?? ''}\0${policy}`; const cached = entries.get(key); if (cached != null && cached.expiresAt > now()) return cached.result; const pending = inflight.get(key); @@ -75,7 +82,7 @@ export function createPullRequestLookup({ const run = (async (): Promise => { let result: PullRequestLookupResult; try { - result = { ok: true, value: await source.find({ repo, branch, token, limits }) }; + result = { ok: true, value: await source.find({ repo, branch, head, token, limits }) }; } catch (error) { if (!(error instanceof PullRequestSourceError)) { logger.warn('[PullRequests] Lookup failed', getSafeErrorMetadata(error)); diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts index 4ea68a5f849..6bf0f018008 100644 --- a/packages/api/src/pulls/types.ts +++ b/packages/api/src/pulls/types.ts @@ -34,6 +34,8 @@ export type PullRequestFindInput = { /** `owner/name`. */ repo: string; branch: string; + /** The commit the conversation last ran at; a pull request must carry it to be the match. */ + head?: string | null; token: string; limits?: PullRequestLookupLimits; }; diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index dcead84ee7e..3f66b5b06c1 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -1134,19 +1134,40 @@ describe('agent pull request config', () => { }); }); - it('accepts an enabled block with an environment variable reference', () => { - const result = parse({ enabled: true, token: '${GITHUB_PULL_REQUEST_TOKEN}' }); - expect(result.success).toBe(true); + const enabled = { + enabled: true, + token: '${GITHUB_PULL_REQUEST_TOKEN}', + allowedRepositories: ['LibreChat-AI/LibreChat'], + }; + + it('accepts an enabled block with a token reference and an allowed repository', () => { + expect(parse(enabled).success).toBe(true); }); it('requires a token reference when enabled', () => { - expect(parse({ enabled: true }).success).toBe(false); + expect(parse({ ...enabled, token: undefined }).success).toBe(false); + }); + + it('requires at least one allowed repository when enabled, so a worker cannot name its own', () => { + expect(parse({ ...enabled, allowedRepositories: undefined }).success).toBe(false); + expect(parse({ ...enabled, allowedRepositories: [] }).success).toBe(false); }); + it.each(['LibreChat-AI/*', 'o/r', 'My.Org/my_repo-2'])('accepts the repository %s', (repo) => { + expect(parse({ ...enabled, allowedRepositories: [repo] }).success).toBe(true); + }); + + it.each(['*/*', '*', 'owner', 'o/r/extra', '../x', 'o/..', 'a b/c', 'o/*x', ''])( + 'rejects %p as an allowed repository', + (repo) => { + expect(parse({ ...enabled, allowedRepositories: [repo] }).success).toBe(false); + }, + ); + it.each(['ghp_abcdef', '${bad name}', '$GITHUB_TOKEN', '${}'])( 'rejects %s as a token because only a reference is allowed', (token) => { - expect(parse({ enabled: true, token }).success).toBe(false); + expect(parse({ ...enabled, token }).success).toBe(false); }, ); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 10b61ac82ca..517ee92efda 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -498,6 +498,17 @@ const skillSyncTenantIdSchema = z message: 'must not be the reserved system tenant id', }); +/** `owner/name`, or `owner/*` for every repository of one owner; never a bare `*`. */ +const pullRequestRepositorySchema = z + .string() + .trim() + .regex(/^[A-Za-z0-9_.-]+\/(?:[A-Za-z0-9_.-]+|\*)$/, { + message: 'must be owner/name or owner/*', + }) + .refine((value) => value.split('/').every((segment) => segment !== '.' && segment !== '..'), { + message: 'must not contain dot segments', + }); + const pullRequestTokenReferenceSchema = z .string() .trim() @@ -1851,6 +1862,10 @@ export const agentsEndpointSchema = baseEndpointSchema /** Environment variable reference holding a read-only GitHub token, e.g. * `${GITHUB_PULL_REQUEST_TOKEN}`. Never the token itself. */ token: pullRequestTokenReferenceSchema.optional(), + /** Repositories the token may be used for, as `owner/name` or `owner/*`. A worker reports + * its own repository, so without this list a user could point the server's token at any + * repository it can read. */ + allowedRepositories: z.array(pullRequestRepositorySchema).max(256).optional(), /** Seconds a looked-up pull request is reused before GitHub is asked again. */ cacheTtlSeconds: z.number().int().min(5).max(3600).optional().default(30), /** Longest one GitHub request may take. Raise it behind a slow proxy. */ @@ -1868,6 +1883,13 @@ export const agentsEndpointSchema = baseEndpointSchema message: 'A token reference is required when pull requests are enabled', }); } + if (value.enabled && (value.allowedRepositories?.length ?? 0) === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['allowedRepositories'], + message: 'At least one allowed repository is required when pull requests are enabled', + }); + } }) .optional(), /** Conversational background-task delivery policy. Automatic completion wakeups are diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 49c0dcc1145..3ff7f8c7a30 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -9527,6 +9527,92 @@ describe('laneGit', () => { }); }); + describe('workspace fence', () => { + const mac = { environmentId: 'code-mac', workspaceId: 'primary' }; + const team = { environmentId: 'code-team', workspaceId: 'shared' }; + const seedRaw = async (decision: Record) => { + const conversationId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId, + user: 'lane-user', + title: 'Fence', + endpoint: 'agents', + messages: [], + ...decision, + }); + return conversationId; + }; + const write = (conversationId: string, workspace: Record) => + methods.setConvoLaneGit({ + user: 'lane-user', + conversationId, + laneGit: { branch: 'feat/x', head }, + workspace: workspace as never, + }); + + it('records for the workspace the conversation is attached to', async () => { + const conversationId = await seedRaw({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [mac], + }); + await expect(write(conversationId, mac)).resolves.toBe(true); + }); + + it('refuses a report from a workspace the conversation moved away from', async () => { + const conversationId = await seedRaw({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [team], + }); + await expect(write(conversationId, mac)).resolves.toBe(false); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toBeNull(); + }); + + it('refuses a report once the conversation has left attached execution', async () => { + const conversationId = await seedRaw({ codeEnvironmentMode: 'without_attached' }); + await expect(write(conversationId, mac)).resolves.toBe(false); + }); + + it('does not let a write queued before a move repopulate the cleared lane', async () => { + const conversationId = await seedRaw({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [mac], + laneGit: { branch: 'old', head, repo: 'o/old', reportedAt: new Date(0) }, + }); + const stored = await methods.getConvoCodeEnvironmentDecision('lane-user', conversationId); + await methods.replaceConvoCodeEnvironmentDecision({ + user: 'lane-user', + conversationId, + expected: { + codeEnvironmentMode: stored?.codeEnvironmentMode, + codeWorkspaces: stored?.codeWorkspaces, + codeEnvironmentRevision: stored?.codeEnvironmentRevision, + }, + codeEnvironmentMode: 'attached', + codeWorkspaces: [team], + }); + await expect(write(conversationId, mac)).resolves.toBe(false); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toBeNull(); + }); + + it('records when no workspace is stored yet, as for an agent default workspace', async () => { + const conversationId = await seedRaw({}); + await expect(write(conversationId, mac)).resolves.toBe(true); + }); + + it('refuses an unrecorded workspace when the caller requires a recorded one', async () => { + const conversationId = await seedRaw({}); + await expect(write(conversationId, { ...mac, required: true })).resolves.toBe(false); + }); + + it('accepts a required workspace that matches the recorded one', async () => { + const conversationId = await seedRaw({ + codeEnvironmentMode: 'attached', + codeWorkspaces: [mac, team], + }); + await expect(write(conversationId, { ...team, required: true })).resolves.toBe(true); + }); + }); + it('keeps a detached or empty lane distinct from an unknown one', async () => { const conversationId = await seed(); const laneGit = { branch: null, head: null }; diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index b7c5b027b76..ea11e25388b 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -336,6 +336,8 @@ export interface ConversationMethods { repo?: string; /** When the worker reported this state; defaults to now. */ reportedAt?: Date; + /** The workspace the command ran in; the write applies only while the chat is still on it. */ + workspace?: { environmentId: string; workspaceId: string; required?: boolean }; }): Promise; getConvoLaneGit( user: string, @@ -3021,9 +3023,13 @@ export function createConversationMethods( * Record the branch and head a conversation's code lane last reported. Owner-scoped and fenced * by report time: the write matches only while the stored report is not newer than this one, so * a delayed older report, from this process or another replica, can never replace a newer - * state. Server-written only: generic saves and imports cannot set it. Resolves to whether the - * write applied; false means a newer report is already stored or there is no such conversation - * for this owner. + * state. When the caller names the workspace the command ran in, the write also matches only + * while the conversation is still attached to it, so a report queued before a move or detach + * cannot bring the old workspace's lane back. A conversation with no stored workspace (an agent + * default) accepts the report unless the caller requires a recorded one. Server-written only: + * generic saves and imports cannot set it. Resolves to whether the write applied; false means a + * newer report is already stored, the workspace no longer matches, or there is no such + * conversation for this owner. */ async function setConvoLaneGit({ user, @@ -3031,12 +3037,14 @@ export function createConversationMethods( laneGit, repo, reportedAt = new Date(), + workspace, }: { user: string; conversationId: string; laneGit: Pick, 'branch' | 'head'>; repo?: string; reportedAt?: Date; + workspace?: { environmentId: string; workspaceId: string; required?: boolean }; }): Promise { const Conversation = mongoose.models.Conversation as Model; const next = { @@ -3045,15 +3053,49 @@ export function createConversationMethods( ...(repo ? { repo } : {}), reportedAt, }; + const workspaceFence = + workspace == null + ? [] + : [ + { + $or: [ + { + codeWorkspaces: { + $elemMatch: { + environmentId: workspace.environmentId, + workspaceId: workspace.workspaceId, + }, + }, + }, + ...(workspace.required === true + ? [] + : [ + { + codeEnvironmentMode: { $ne: 'without_attached' }, + $or: [ + { codeWorkspaces: { $exists: false } }, + { codeWorkspaces: null }, + { codeWorkspaces: { $size: 0 } }, + ], + }, + ]), + ], + }, + ]; const result = await withoutMeiliIndexing( Conversation.updateOne( { user, conversationId, - $or: [ - { 'laneGit.reportedAt': { $exists: false } }, - { 'laneGit.reportedAt': null }, - { 'laneGit.reportedAt': { $lte: reportedAt } }, + $and: [ + { + $or: [ + { 'laneGit.reportedAt': { $exists: false } }, + { 'laneGit.reportedAt': null }, + { 'laneGit.reportedAt': { $lte: reportedAt } }, + ], + }, + ...workspaceFence, ], }, { $set: { laneGit: next } }, From ab87dd99b516b905b1ec8bc6bb6e93f9e459faaa Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:07:31 +0200 Subject: [PATCH 05/10] fix: Order Lane Reports by a Database Sequence and Tighten Head Matching Replace wall clock ordering of lane reports with a per conversation counter reserved in the database when the command settles, so replica clock skew cannot reject a newer report. Treat a pull request whose head is behind the recorded commit as no match, and expose the candidate and comparison bounds of the history search as settings. Read the app config and the owner scoped lane together, inject the HTTP transport from the route instead of falling back to a global, skip a report identical to the last one written, and name the Contents permission that commit comparison needs. --- api/server/routes/convos.js | 2 +- api/server/services/ToolService.js | 2 + .../services/__tests__/ToolService.spec.js | 3 + librechat.example.yaml | 7 +- packages/api/src/code/lane.spec.ts | 376 ++++++++++-------- packages/api/src/code/lane.ts | 87 ++-- packages/api/src/pulls/github.spec.ts | 57 ++- packages/api/src/pulls/github.ts | 39 +- packages/api/src/pulls/handler.spec.ts | 56 ++- packages/api/src/pulls/handler.ts | 21 +- packages/api/src/pulls/lookup.spec.ts | 13 + packages/api/src/pulls/lookup.ts | 2 + packages/api/src/pulls/types.ts | 4 + packages/data-provider/src/config.spec.ts | 16 +- packages/data-provider/src/config.ts | 5 + .../src/methods/conversation.spec.ts | 143 ++++--- .../data-schemas/src/methods/conversation.ts | 66 ++- packages/data-schemas/src/schema/convo.ts | 7 +- packages/data-schemas/src/types/convo.ts | 6 +- 19 files changed, 610 insertions(+), 302 deletions(-) diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 6a1dea1f03e..4c05abb57f6 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -173,7 +173,7 @@ const backgroundTaskCancelHandler = createBackgroundTaskCancelHandler({ const conversationPullRequestHandler = createConversationPullRequestHandler({ getConvoLaneGit: db.getConvoLaneGit, getAppConfig, - lookup: createPullRequestLookup({ source: createGitHubPullRequestSource() }), + lookup: createPullRequestLookup({ source: createGitHubPullRequestSource({ fetchFn: fetch }) }), env: process.env, }); router.use(requireJwtAuth); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index eae450be2cc..f368a6bd9ad 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -136,6 +136,7 @@ const { getRoleByName, setConvoLaneGit, getConvoOwnership, + reserveConvoLaneGitSeq, } = require('~/models'); const { getFlowStateManager, getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); @@ -2401,6 +2402,7 @@ async function loadToolsForExecution({ workspaceId: codeExecutionContext.codeWorkspace.workspaceId, }, getConvoOwnership, + reserveConvoLaneGitSeq, setConvoLaneGit, }), linkedWorktrees: codeExecutionContext.codeWorkspace.linkedWorktrees, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 7a0a90b8303..4dbbccf3372 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -26,6 +26,7 @@ const mockLaneGitRecorder = jest.fn(); const mockCreateLaneGitRecorder = jest.fn(() => mockLaneGitRecorder); const mockSetConvoLaneGit = jest.fn(); const mockGetConvoOwnership = jest.fn(); +const mockReserveConvoLaneGitSeq = jest.fn(); const attachedWorkspaceOperations = [ 'read_file', 'search_text', @@ -178,6 +179,7 @@ jest.mock('~/models', () => ({ getRoleByName: (...args) => mockGetRoleByName(...args), setConvoLaneGit: (...args) => mockSetConvoLaneGit(...args), getConvoOwnership: (...args) => mockGetConvoOwnership(...args), + reserveConvoLaneGitSeq: (...args) => mockReserveConvoLaneGitSeq(...args), })); jest.mock('~/config', () => ({ getFlowStateManager: jest.fn(() => mockFlowManager), @@ -3295,6 +3297,7 @@ describe('ToolService - Action Capability Gating', () => { conversationId: 'resolved-convo', workspace: { environmentId: 'personal-machine', workspaceId: 'project-a' }, getConvoOwnership: expect.any(Function), + reserveConvoLaneGitSeq: expect.any(Function), setConvoLaneGit: expect.any(Function), }), ); diff --git a/librechat.example.yaml b/librechat.example.yaml index 1955123195f..fff940160f9 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -926,8 +926,9 @@ endpoints: # # a code worker built from LibreChat-AI/code-interpreter#311 or later that reports its # # branch, with CODEAPI_BRIDGE_LANE_GIT=true set on the Code API; without both, every chat # # simply shows no pull request. It also needs a GitHub token held on the server. Use a - # # fine-grained token limited to the repositories involved, with read-only "Pull requests" - # # and "Checks" permissions: without "Checks" every matched pull request fails to load. + # # fine-grained token limited to the repositories involved, with read-only "Pull requests", + # # "Checks" and "Contents" permissions. Without "Checks" every matched pull request fails to + # # load, and without "Contents" a chat whose branch has moved on since its last command does. # pullRequests: # enabled: false # # Environment variable reference only, never the token itself. @@ -941,6 +942,8 @@ endpoints: # requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60) # lookupTimeoutSeconds: 30 # longest the whole lookup may take (1 to 120) # maxCheckRunPages: 10 # pages read before checks are reported as running (1 to 50) + # maxCandidatePullRequests: 10 # pull requests listed per state when matching a reused branch (1 to 100) + # maxHeadComparisons: 3 # candidates compared with the chat's last commit (0 to 20) # # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in. # # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools. # # (optional) Require user approval before matching tool calls. Disabled by default. diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index b31929ec7ed..1ea821f636b 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -11,16 +11,35 @@ jest.mock('@librechat/data-schemas', () => ({ const head = 'a'.repeat(40); const laneGit = { branch: 'feat/pr-chip', head }; +/** A reserver that hands out 1, 2, 3 ... like the database counter does. */ +const counter = () => { + let next = 0; + return jest.fn(async () => ++next); +}; + describe('createLaneGitRecorder', () => { - it('writes the reported state for the owner and conversation', async () => { + const make = (overrides: Record = {}) => { const setConvoLaneGit = jest.fn().mockResolvedValue(true); - const record = createLaneGitRecorder({ user: 'u1', conversationId: 'c1', setConvoLaneGit }); + const reserveConvoLaneGitSeq = counter(); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'c1', + reserveConvoLaneGitSeq, + setConvoLaneGit, + ...overrides, + }); + return { record, setConvoLaneGit, reserveConvoLaneGitSeq }; + }; + + it('writes the reported state with a reserved sequence number', async () => { + const { record, setConvoLaneGit, reserveConvoLaneGitSeq } = make(); await expect(record?.(laneGit)).resolves.toBe(true); + expect(reserveConvoLaneGitSeq).toHaveBeenCalledWith('u1', 'c1'); expect(setConvoLaneGit).toHaveBeenCalledWith({ user: 'u1', conversationId: 'c1', laneGit, - reportedAt: expect.any(Date), + seq: 1, }); }); @@ -30,56 +49,52 @@ describe('createLaneGitRecorder', () => { ['a missing conversation', { user: 'u1', conversationId: undefined }], ['an empty conversation', { user: 'u1', conversationId: '' }], ])('does not record with %s', (_label, ids) => { - const setConvoLaneGit = jest.fn(); - expect(createLaneGitRecorder({ ...ids, setConvoLaneGit })).toBeUndefined(); + const { record, setConvoLaneGit } = make(ids); + expect(record).toBeUndefined(); expect(setConvoLaneGit).not.toHaveBeenCalled(); }); it('stores a plain owner/name repository with the lane', async () => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); - const record = createLaneGitRecorder({ - user: 'u1', - conversationId: 'c1', - repo: 'LibreChat-AI/LibreChat', - setConvoLaneGit, - }); + const { record, setConvoLaneGit } = make({ repo: 'LibreChat-AI/LibreChat' }); await record?.(laneGit); - expect(setConvoLaneGit).toHaveBeenCalledWith({ - user: 'u1', - conversationId: 'c1', - laneGit, - reportedAt: expect.any(Date), - repo: 'LibreChat-AI/LibreChat', - }); + expect(setConvoLaneGit).toHaveBeenCalledWith( + expect.objectContaining({ repo: 'LibreChat-AI/LibreChat' }), + ); }); it.each(['../x', 'o/..', 'a b/c', 'owner', 'o/r/extra', 'x'.repeat(300)])( 'drops the unsafe repository %s but still records the lane', async (repo) => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); - const record = createLaneGitRecorder({ - user: 'u1', - conversationId: 'c1', - repo, - setConvoLaneGit, - }); + const { record, setConvoLaneGit } = make({ repo }); await record?.(laneGit); - expect(setConvoLaneGit).toHaveBeenCalledWith({ - user: 'u1', - conversationId: 'c1', - laneGit, - reportedAt: expect.any(Date), - }); + expect(setConvoLaneGit).toHaveBeenCalledTimes(1); + expect(setConvoLaneGit.mock.calls[0][0]).not.toHaveProperty('repo'); }, ); - it('swallows a database failure and logs only safe metadata', async () => { + it('swallows a write failure and logs only safe metadata', async () => { const setConvoLaneGit = jest.fn().mockRejectedValue(new Error('mongodb://user:secret@host')); - const record = createLaneGitRecorder({ user: 'u1', conversationId: 'c1', setConvoLaneGit }); + const { record } = make({ setConvoLaneGit }); await expect(record?.(laneGit)).resolves.toBe(false); expect(logger.warn).toHaveBeenCalledTimes(1); expect(JSON.stringify((logger.warn as jest.Mock).mock.calls)).not.toContain('secret'); }); + + it('swallows a reservation failure and writes nothing', async () => { + const reserveConvoLaneGitSeq = jest.fn().mockRejectedValue(new Error('mongodb://u:secret@h')); + const { record, setConvoLaneGit } = make({ reserveConvoLaneGitSeq }); + await expect(record?.(laneGit)).resolves.toBe(false); + expect(setConvoLaneGit).not.toHaveBeenCalled(); + expect(JSON.stringify((logger.warn as jest.Mock).mock.calls)).not.toContain('secret'); + }); + + it('writes nothing when the conversation is not saved yet', async () => { + const { record, setConvoLaneGit } = make({ + reserveConvoLaneGitSeq: jest.fn().mockResolvedValue(null), + }); + await expect(record?.(laneGit)).resolves.toBe(false); + expect(setConvoLaneGit).not.toHaveBeenCalled(); + }); }); describe('attached bash tool lane reporting', () => { @@ -140,151 +155,202 @@ describe('attached bash tool lane reporting', () => { }); }); -describe('createLaneGitRecorder write order', () => { +describe('createLaneGitRecorder ordering', () => { const flush = () => new Promise((resolve) => setImmediate(resolve)); - function controlledWriter() { - const events: string[] = []; - const releases: Array<(ok?: boolean) => void> = []; - const fail: Array<() => void> = []; - const setConvoLaneGit = jest.fn( - ({ laneGit: reported }: { laneGit: { branch: string | null } }) => - new Promise((resolve, reject) => { - events.push(`start:${reported.branch}`); - releases.push((ok = true) => { - events.push(`end:${reported.branch}`); - resolve(ok); - }); - fail.push(() => reject(new Error('db down'))); + it('takes sequence numbers in report order and writes each with its own', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-1', + reserveConvoLaneGitSeq: counter(), + setConvoLaneGit, + }); + await Promise.all([record?.({ branch: 'a', head }), record?.({ branch: 'b', head })]); + const writes = setConvoLaneGit.mock.calls.map(([call]) => [call.laneGit.branch, call.seq]); + expect(writes.sort()).toEqual([ + ['a', 1], + ['b', 2], + ]); + }); + + it('gives a later report the higher number even when the first reservation is slow', async () => { + let issued = 0; + const releases: Array<() => void> = []; + /** The first reservation takes longer than any later one, as a slow replica round trip can. */ + const reserve = jest.fn( + () => + new Promise((resolve) => { + const slow = issued === 0; + issued += 1; + const number = issued; + if (slow) releases.push(() => resolve(number)); + else resolve(number); }), ); - return { events, releases, fail, setConvoLaneGit }; - } - - it('starts a later write only after the earlier one has finished', async () => { - const { events, releases, setConvoLaneGit } = controlledWriter(); + const setConvoLaneGit = jest.fn().mockResolvedValue(true); const record = createLaneGitRecorder({ user: 'u1', - conversationId: 'order-1', + conversationId: 'order-slow', + reserveConvoLaneGitSeq: reserve, setConvoLaneGit, }); - const first = record?.({ branch: 'a', head }); - const second = record?.({ branch: 'b', head }); + const first = record?.({ branch: 'first', head }); + const second = record?.({ branch: 'second', head }); await flush(); - expect(events).toEqual(['start:a']); + expect(reserve).toHaveBeenCalledTimes(1); releases[0](); - await first; + await Promise.all([first, second]); + const bySeq = setConvoLaneGit.mock.calls + .map(([call]) => [call.seq, call.laneGit.branch]) + .sort(); + expect(bySeq).toEqual([ + [1, 'first'], + [2, 'second'], + ]); + }); + + it('does not wait for one write to finish before reserving the next report', async () => { + const reserve = counter(); + const releases: Array<() => void> = []; + const setConvoLaneGit = jest.fn( + () => new Promise((resolve) => releases.push(() => resolve(true))), + ); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-2', + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit, + }); + void record?.({ branch: 'a', head }); + void record?.({ branch: 'b', head }); await flush(); - expect(events).toEqual(['start:a', 'end:a', 'start:b']); - releases[1](); - await second; - expect(events).toEqual(['start:a', 'end:a', 'start:b', 'end:b']); + expect(reserve).toHaveBeenCalledTimes(2); + expect(setConvoLaneGit).toHaveBeenCalledTimes(2); + releases.forEach((release) => release()); }); - it('orders writes across recorders built for the same conversation', async () => { - const { events, releases, setConvoLaneGit } = controlledWriter(); + it('orders reservations across recorders built for the same conversation', async () => { + const reserve = counter(); + const setConvoLaneGit = jest.fn().mockResolvedValue(true); const make = () => - createLaneGitRecorder({ user: 'u1', conversationId: 'order-2', setConvoLaneGit }); - const first = make()?.({ branch: 'a', head }); - const second = make()?.({ branch: 'b', head }); - await flush(); - expect(events).toEqual(['start:a']); - releases[0](); - await first; - await flush(); - releases[1](); - await second; - expect(events).toEqual(['start:a', 'end:a', 'start:b', 'end:b']); + createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-3', + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit, + }); + await Promise.all([make()?.({ branch: 'a', head }), make()?.({ branch: 'b', head })]); + const bySeq = setConvoLaneGit.mock.calls + .map(([call]) => [call.seq, call.laneGit.branch]) + .sort(); + expect(bySeq).toEqual([ + [1, 'a'], + [2, 'b'], + ]); }); - it('does not make one conversation wait for another', async () => { - const { events, setConvoLaneGit } = controlledWriter(); - const one = createLaneGitRecorder({ user: 'u1', conversationId: 'order-3', setConvoLaneGit }); - const other = createLaneGitRecorder({ user: 'u1', conversationId: 'order-4', setConvoLaneGit }); - void one?.({ branch: 'a', head }); - void other?.({ branch: 'b', head }); + it('does not make one conversation or one user wait for another', async () => { + const releases: Array<() => void> = []; + const reserve = jest.fn( + () => new Promise((resolve) => releases.push(() => resolve(1))), + ); + const make = (user: string, conversationId: string) => + createLaneGitRecorder({ + user, + conversationId, + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit: jest.fn().mockResolvedValue(true), + }); + void make('u1', 'order-4')?.({ branch: 'a', head }); + void make('u1', 'order-5')?.({ branch: 'b', head }); + void make('u2', 'order-4')?.({ branch: 'c', head }); await flush(); - expect(events).toEqual(['start:a', 'start:b']); + expect(reserve).toHaveBeenCalledTimes(3); + releases.forEach((release) => release()); }); - it('does not make one user wait for another on the same conversation id', async () => { - const { events, setConvoLaneGit } = controlledWriter(); - const make = (user: string) => - createLaneGitRecorder({ user, conversationId: 'order-5', setConvoLaneGit }); - void make('u1')?.({ branch: 'a', head }); - void make('u2')?.({ branch: 'b', head }); - await flush(); - expect(events).toEqual(['start:a', 'start:b']); + it('keeps reserving after one reservation fails', async () => { + const reserve = jest.fn().mockRejectedValueOnce(new Error('db down')).mockResolvedValue(7); + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-6', + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit, + }); + await expect(record?.({ branch: 'a', head })).resolves.toBe(false); + await expect(record?.({ branch: 'b', head })).resolves.toBe(true); + expect(setConvoLaneGit.mock.calls[0][0]).toMatchObject({ seq: 7 }); }); - it('stamps each report when it arrives, not when its write finally runs', async () => { - const stamps: Array<{ branch: string | null; at: number }> = []; - const releases: Array<() => void> = []; - const setConvoLaneGit = jest.fn( - ({ - laneGit: reported, - reportedAt, - }: { - laneGit: { branch: string | null }; - reportedAt: Date; - }) => - new Promise((resolve) => { - stamps.push({ branch: reported.branch, at: reportedAt.getTime() }); - releases.push(() => resolve(true)); - }), - ); - const times = [1_000, 2_000]; + it('reports a write the database rejected as not applied, so a stale report is not remembered', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true); const record = createLaneGitRecorder({ user: 'u1', conversationId: 'order-7', + reserveConvoLaneGitSeq: counter(), setConvoLaneGit, - now: () => new Date(times.shift() ?? 0), }); - const first = record?.({ branch: 'a', head }); - const second = record?.({ branch: 'b', head }); - await flush(); - releases[0](); - await first; - await flush(); - releases[1](); - await second; - expect(stamps).toEqual([ - { branch: 'a', at: 1_000 }, - { branch: 'b', at: 2_000 }, - ]); + await expect(record?.(laneGit)).resolves.toBe(false); + await expect(record?.(laneGit)).resolves.toBe(true); + expect(setConvoLaneGit).toHaveBeenCalledTimes(2); + }); + + it('skips a report identical to the last one it wrote', async () => { + const reserve = counter(); + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-8', + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit, + }); + await expect(record?.(laneGit)).resolves.toBe(true); + await expect(record?.(laneGit)).resolves.toBe(false); + await expect(record?.(laneGit)).resolves.toBe(false); + expect(reserve).toHaveBeenCalledTimes(1); + expect(setConvoLaneGit).toHaveBeenCalledTimes(1); }); - it('still runs the next write after one fails', async () => { - const { events, releases, fail, setConvoLaneGit } = controlledWriter(); + it('writes again once the state changes, including back to an earlier one', async () => { + const setConvoLaneGit = jest.fn().mockResolvedValue(true); const record = createLaneGitRecorder({ user: 'u1', - conversationId: 'order-6', + conversationId: 'order-9', + reserveConvoLaneGitSeq: counter(), setConvoLaneGit, }); - const first = record?.({ branch: 'a', head }); - const second = record?.({ branch: 'b', head }); - await flush(); - fail[0](); - await expect(first).resolves.toBe(false); - await flush(); - expect(events).toEqual(['start:a', 'start:b']); - releases[1](); - await expect(second).resolves.toBe(true); + await record?.({ branch: 'a', head }); + await record?.({ branch: 'b', head }); + await record?.({ branch: 'a', head }); + expect(setConvoLaneGit.mock.calls.map(([call]) => call.laneGit.branch)).toEqual([ + 'a', + 'b', + 'a', + ]); }); }); describe('createLaneGitRecorder target conversation', () => { const workspace = { environmentId: 'code-mac', workspaceId: 'primary' }; - const written = (setConvoLaneGit: jest.Mock) => setConvoLaneGit.mock.calls.map(([call]) => call); - - it('passes the workspace it ran in, so a stale writer can be fenced', async () => { + const build = (overrides: Record = {}) => { const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const reserveConvoLaneGitSeq = counter(); const record = createLaneGitRecorder({ user: 'u1', conversationId: 'target-1', workspace, + reserveConvoLaneGitSeq, setConvoLaneGit, + ...overrides, }); + return { record, setConvoLaneGit, reserveConvoLaneGitSeq }; + }; + const written = (setConvoLaneGit: jest.Mock) => setConvoLaneGit.mock.calls.map(([call]) => call); + + it('passes the workspace it ran in, so a stale writer can be fenced', async () => { + const { record, setConvoLaneGit } = build(); await record?.(laneGit); expect(written(setConvoLaneGit)[0]).toMatchObject({ conversationId: 'target-1', @@ -294,19 +360,16 @@ describe('createLaneGitRecorder target conversation', () => { }); it('records a subagent thread on the visible conversation, and requires its workspace to match', async () => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); const getConvoOwnership = jest.fn().mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root', parentConversationId: 'mid' }, }); - const record = createLaneGitRecorder({ - user: 'u1', + const { record, setConvoLaneGit, reserveConvoLaneGitSeq } = build({ conversationId: 'child-thread', - workspace, getConvoOwnership, - setConvoLaneGit, }); await record?.(laneGit); expect(getConvoOwnership).toHaveBeenCalledWith('u1', 'child-thread'); + expect(reserveConvoLaneGitSeq).toHaveBeenCalledWith('u1', 'visible-root'); expect(written(setConvoLaneGit)[0]).toMatchObject({ conversationId: 'visible-root', workspace: { ...workspace, required: true }, @@ -314,31 +377,17 @@ describe('createLaneGitRecorder target conversation', () => { }); it('records an ordinary conversation on itself', async () => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); const getConvoOwnership = jest.fn().mockResolvedValue({ user: 'u1' }); - const record = createLaneGitRecorder({ - user: 'u1', - conversationId: 'plain', - workspace, - getConvoOwnership, - setConvoLaneGit, - }); + const { record, setConvoLaneGit } = build({ conversationId: 'plain', getConvoOwnership }); await record?.(laneGit); expect(written(setConvoLaneGit)[0].conversationId).toBe('plain'); }); it('looks the conversation up once however many reports follow', async () => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); const getConvoOwnership = jest.fn().mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' }, }); - const record = createLaneGitRecorder({ - user: 'u1', - conversationId: 'child-2', - workspace, - getConvoOwnership, - setConvoLaneGit, - }); + const { record, setConvoLaneGit } = build({ conversationId: 'child-2', getConvoOwnership }); await record?.({ branch: 'a', head }); await record?.({ branch: 'b', head }); expect(getConvoOwnership).toHaveBeenCalledTimes(1); @@ -349,18 +398,11 @@ describe('createLaneGitRecorder target conversation', () => { }); it('skips a report it cannot place rather than guess, and tries again on the next', async () => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); const getConvoOwnership = jest .fn() .mockRejectedValueOnce(new Error('mongodb://user:secret@host')) .mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' } }); - const record = createLaneGitRecorder({ - user: 'u1', - conversationId: 'child-3', - workspace, - getConvoOwnership, - setConvoLaneGit, - }); + const { record, setConvoLaneGit } = build({ conversationId: 'child-3', getConvoOwnership }); await expect(record?.({ branch: 'a', head })).resolves.toBe(false); expect(setConvoLaneGit).not.toHaveBeenCalled(); expect(JSON.stringify((logger.warn as jest.Mock).mock.calls)).not.toContain('secret'); @@ -369,15 +411,13 @@ describe('createLaneGitRecorder target conversation', () => { }); it('does not write a subagent lane to the parent without a workspace to verify it against', async () => { - const setConvoLaneGit = jest.fn().mockResolvedValue(true); const getConvoOwnership = jest.fn().mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' }, }); - const record = createLaneGitRecorder({ - user: 'u1', + const { record, setConvoLaneGit } = build({ conversationId: 'child-4', + workspace: undefined, getConvoOwnership, - setConvoLaneGit, }); await expect(record?.(laneGit)).resolves.toBe(false); expect(setConvoLaneGit).not.toHaveBeenCalled(); diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index cc4a09c4dcc..f262d4f941d 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -10,12 +10,15 @@ export type LaneGitWriter = (input: { conversationId: string; laneGit: WorkspaceLaneGit; repo?: string; - /** When the command settled; the database ignores a report older than the one it holds. */ - reportedAt: Date; + /** Reserved when the command settled; the database ignores a report below the one it holds. */ + seq: number; /** The database ignores the report once the conversation is no longer on this workspace. */ workspace?: LaneWorkspace; }) => Promise; +/** Draws the next report sequence number from the database, so every replica shares one order. */ +export type LaneSeqReserver = (user: string, conversationId: string) => Promise; + /** Reads what the recorder needs to place a conversation: whether it is a subagent thread. */ export type LaneOwnershipReader = ( user: string, @@ -31,21 +34,26 @@ const isSafeRepo = (repo: string | undefined): repo is string => REPO_PATTERN.test(repo) && repo.split('/').every((segment) => segment !== '.' && segment !== '..'); +type Placement = { conversationId: string; required: boolean }; +type Reservation = Placement & { seq: number }; + /** - * The tail of each conversation's pending writes in this process. It keeps one process from - * racing its own writes; correctness across replicas comes from `reportedAt`, which the database - * fences on, so an older report is ignored wherever it lands. Entries leave the map as soon as - * their chain drains, so it only holds conversations with a write in flight. + * The tail of each conversation's pending sequence reservations in this process. Numbers are + * drawn one at a time in report order, so one process never takes a lower number for a later + * report. Writes are not queued: each carries its number and the database ignores a lower one, + * so a slow or reordered write cannot replace a newer state, here or on another replica. Entries + * leave the map as soon as their chain drains. */ -const pendingWrites = new Map>(); +const reservationTails = new Map>(); /** * Records the lane state a finished command reported, for the owner's conversation. It never * throws and is never awaited by the command: the branch is a header affordance, so a database - * outage must not fail or delay the agent's tool call. Writes for one conversation run in the - * order they were reported, and each carries the time it was reported so the database can ignore - * a stale one. Resolves to whether the write applied, which is false when a newer report is - * already stored and for a conversation not saved yet. A repo that is not a plain `owner/name` + * outage must not fail or delay the agent's tool call. Each report takes a sequence number from + * the database when it arrives and is written with it, so the newest report wins whatever order + * the writes land in. A report identical to the last one this recorder wrote is skipped. Resolves + * to whether the write applied, which is false when a newer report is already stored, for a + * conversation not saved yet, and for a skipped repeat. A repo that is not a plain `owner/name` * is dropped rather than stored. */ export function createLaneGitRecorder({ @@ -54,16 +62,16 @@ export function createLaneGitRecorder({ repo, workspace, getConvoOwnership, + reserveConvoLaneGitSeq, setConvoLaneGit, - now = () => new Date(), }: { user: string | undefined; conversationId: string | undefined; repo?: string; workspace?: { environmentId: string; workspaceId: string }; getConvoOwnership?: LaneOwnershipReader; + reserveConvoLaneGitSeq: LaneSeqReserver; setConvoLaneGit: LaneGitWriter; - now?: () => Date; }): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { if (!user || !conversationId) return undefined; const safeRepo = isSafeRepo(repo) ? repo : undefined; @@ -75,10 +83,10 @@ export function createLaneGitRecorder({ * root's recorded workspace, since nothing else ties the child to it. The lookup is made once; * a failed one is retried on the next report. */ - let target: Promise<{ conversationId: string; required: boolean }> | undefined; - const resolveTarget = (): Promise<{ conversationId: string; required: boolean }> => { + let target: Promise | undefined; + const resolveTarget = (): Promise => { if (target == null) { - const resolving = (async () => { + const resolving = (async (): Promise => { if (getConvoOwnership == null) return { conversationId, required: false }; const owned = await getConvoOwnership(user, conversationId); const root = owned?.subagentThread?.rootConversationId; @@ -94,18 +102,29 @@ export function createLaneGitRecorder({ return target; }; - const write = async (laneGit: WorkspaceLaneGit, reportedAt: Date): Promise => { + /** Never rejects, so a failed reservation cannot stall the ones queued behind it. */ + const reserve = async (): Promise => { try { const placed = await resolveTarget(); - if (placed.required && workspace == null) return false; + if (placed.required && workspace == null) return null; + const seq = await reserveConvoLaneGitSeq(user, placed.conversationId); + return seq == null ? null : { ...placed, seq }; + } catch (error) { + logger.warn('[LaneGit] Failed to reserve a lane report', getSafeErrorMetadata(error)); + return null; + } + }; + + const write = async (laneGit: WorkspaceLaneGit, reserved: Reservation): Promise => { + try { return await setConvoLaneGit({ user, - conversationId: placed.conversationId, + conversationId: reserved.conversationId, laneGit, ...(safeRepo ? { repo: safeRepo } : {}), - reportedAt, + seq: reserved.seq, ...(workspace - ? { workspace: { ...workspace, ...(placed.required ? { required: true } : {}) } } + ? { workspace: { ...workspace, ...(reserved.required ? { required: true } : {}) } } : {}), }); } catch (error) { @@ -114,16 +133,24 @@ export function createLaneGitRecorder({ } }; + /** The state this recorder last wrote, so a command that changed nothing costs no database work. */ + let lastWritten: string | undefined; + return (laneGit) => { - /** Stamped when the command settles, not when its write finally runs. */ - const reportedAt = now(); - const previous = pendingWrites.get(queueKey) ?? Promise.resolve(); - /** `write` never rejects, so a failed write cannot stall the writes queued behind it. */ - const next = previous.then(() => write(laneGit, reportedAt)); - pendingWrites.set(queueKey, next); - void next.then(() => { - if (pendingWrites.get(queueKey) === next) pendingWrites.delete(queueKey); + const state = JSON.stringify([laneGit.branch, laneGit.head]); + if (state === lastWritten) return Promise.resolve(false); + + const previous = reservationTails.get(queueKey) ?? Promise.resolve(); + const reservation = previous.then(() => reserve()); + reservationTails.set(queueKey, reservation); + void reservation.then(() => { + if (reservationTails.get(queueKey) === reservation) reservationTails.delete(queueKey); + }); + return reservation.then(async (reserved) => { + if (reserved == null) return false; + const applied = await write(laneGit, reserved); + if (applied) lastWritten = state; + return applied; }); - return next; }; } diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index 6d75c53f3f3..183886c4b88 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -378,14 +378,28 @@ describe('matching the recorded head', () => { expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); }); - it.each(['ahead', 'behind', 'identical'])( - 'accepts a pull request on the same line of commits (%s)', + it.each(['ahead', 'identical'])( + 'accepts a pull request that is, or builds on, the recorded commit (%s)', async (status) => { const { source } = sourceFor(routes(status)); await expect(findWith(source, recorded)).resolves.toMatchObject({ number: 7 }); }, ); + it('rejects a pull request whose head is older than the recorded commit (behind)', async () => { + const { source } = sourceFor({ ...routes('behind'), '/pulls?state=closed': () => json([]) }); + await expect(findWith(source, recorded)).resolves.toBeNull(); + }); + + it('asks GitHub to compare the recorded commit to the candidate, in that order', async () => { + const { source, fetchFn } = sourceFor(routes('ahead')); + await findWith(source, recorded); + const url = String( + fetchFn.mock.calls.map(([u]) => String(u)).find((u) => u.includes('/compare/')), + ); + expect(url).toContain(`/compare/${recorded}...${sha}`); + }); + it('rejects a pull request whose commits diverged from the recorded head', async () => { const { source } = sourceFor({ ...routes('diverged'), '/pulls?state=closed': () => json([]) }); await expect(findWith(source, recorded)).resolves.toBeNull(); @@ -414,6 +428,45 @@ describe('matching the recorded head', () => { }); }); + it('compares as many candidates as configured, no more', async () => { + const many = Array.from({ length: 10 }, (_, index) => ({ + number: index + 1, + state: 'open', + head: { sha }, + })); + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json(many), + '/pulls?state=closed': () => json(many), + '/compare/': compare('diverged'), + }); + await expect( + source.find({ + repo: 'o/r', + branch: 'feat/x', + head: recorded, + token: 't', + limits: { maxHeadComparisons: 5 }, + }), + ).resolves.toBeNull(); + expect(fetchFn.mock.calls.filter(([u]) => String(u).includes('/compare/'))).toHaveLength(5); + }); + + it('lists as many candidate pull requests as configured', async () => { + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json([]), + '/pulls?state=closed': () => json([]), + }); + await source.find({ + repo: 'o/r', + branch: 'feat/x', + head: recorded, + token: 't', + limits: { maxCandidatePullRequests: 40 }, + }); + const urls = fetchFn.mock.calls.map(([u]) => String(u)); + expect(urls.every((u) => u.includes('per_page=40'))).toBe(true); + }); + it('limits how many candidates it compares', async () => { const many = Array.from({ length: 10 }, (_, index) => ({ number: index + 1, diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index 35dcfb8a67f..c1e803096aa 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -13,10 +13,14 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const DEFAULT_LOOKUP_TIMEOUT_MS = 30_000; const DEFAULT_MAX_CHECK_RUN_PAGES = 10; const CHECK_RUN_PAGE_SIZE = 100; -/** Bounds the extra requests spent matching a pull request to the recorded commit. */ -const MAX_HEAD_COMPARISONS = 3; -/** Comparison results meaning the pull request and the recorded commit share a line of history. */ -const SAME_LINE_STATUSES = new Set(['ahead', 'behind', 'identical']); +const DEFAULT_MAX_CANDIDATE_PULL_REQUESTS = 10; +const DEFAULT_MAX_HEAD_COMPARISONS = 3; +/** + * Comparing `recorded...candidate`: `ahead` means the candidate builds on the recorded commit and + * `identical` that it is the recorded commit. `behind` means the candidate does not contain it + * (the recorded commit is newer), and `diverged` that the histories split, so neither matches. + */ +const CONTAINS_RECORDED_STATUSES = new Set(['ahead', 'identical']); const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; const MAX_TITLE_LENGTH = 256; const REPO_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; @@ -200,9 +204,13 @@ function retryAfterMs(response: Response): number | undefined { const isPathSegment = (value: string): boolean => value !== '.' && value !== '..'; export function createGitHubPullRequestSource({ - fetchFn = fetch, + fetchFn, apiBase = GITHUB_API_BASE, -}: { fetchFn?: PullRequestFetch; apiBase?: string } = {}): PullRequestSource { +}: { + /** The deployment's HTTP client, so proxying and instrumentation are decided by the caller. */ + fetchFn: PullRequestFetch; + apiBase?: string; +}): PullRequestSource { /** One lookup's credential and deadlines, shared by every request it makes. */ type Lookup = { token: string; @@ -210,6 +218,8 @@ export function createGitHubPullRequestSource({ /** Aborts when the whole lookup runs out of time. */ deadline: AbortSignal; maxCheckRunPages: number; + maxCandidatePullRequests: number; + maxHeadComparisons: number; }; async function getJson(pathname: string, lookup: Lookup): Promise { @@ -272,6 +282,9 @@ export function createGitHubPullRequestSource({ requestTimeoutMs: limits?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, deadline: AbortSignal.timeout(limits?.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS), maxCheckRunPages: limits?.maxCheckRunPages ?? DEFAULT_MAX_CHECK_RUN_PAGES, + maxCandidatePullRequests: + limits?.maxCandidatePullRequests ?? DEFAULT_MAX_CANDIDATE_PULL_REQUESTS, + maxHeadComparisons: limits?.maxHeadComparisons ?? DEFAULT_MAX_HEAD_COMPARISONS, }; const match = REPO_PATTERN.exec(repo); if (match == null || branch.length === 0) return null; @@ -294,19 +307,19 @@ export function createGitHubPullRequestSource({ let comparisons = 0; /** * A branch name can be deleted and reused, so with a recorded commit a pull request counts - * only when it carries that commit or builds on it. A commit GitHub no longer knows is no - * match, and the number of comparisons per lookup is bounded. + * only when its head is that commit or builds on it. A commit GitHub no longer knows is no + * match, and both the candidates listed and the comparisons made are bounded by config. */ const matches = async (candidate: ListItem): Promise => { if (recorded == null || candidate.sha === recorded) return true; - if (comparisons >= MAX_HEAD_COMPARISONS) return false; + if (comparisons >= lookup.maxHeadComparisons) return false; comparisons += 1; const comparison = await getJson(`${base}/compare/${recorded}...${candidate.sha}`, lookup); if (comparison == null) return false; if (!isRecord(comparison) || typeof comparison.status !== 'string') { throw new PullRequestSourceError('UPSTREAM_ERROR'); } - return SAME_LINE_STATUSES.has(comparison.status); + return CONTAINS_RECORDED_STATUSES.has(comparison.status); }; const choose = async (candidates: ListItem[]): Promise => { for (const candidate of candidates) { @@ -315,11 +328,13 @@ export function createGitHubPullRequestSource({ return null; }; /** Open first on its own, so closed history on a reused branch name cannot hide it. */ - const open = await listPulls('open', 10); + const open = await listPulls('open', lookup.maxCandidatePullRequests); if (open === undefined) return null; const chosen = (await choose(open)) ?? - (await choose((await listPulls('closed', recorded == null ? 1 : 10)) ?? [])); + (await choose( + (await listPulls('closed', recorded == null ? 1 : lookup.maxCandidatePullRequests)) ?? [], + )); if (chosen == null) return null; const [pull, checks] = await Promise.all([ diff --git a/packages/api/src/pulls/handler.spec.ts b/packages/api/src/pulls/handler.spec.ts index a987e3bb499..7657f901e27 100644 --- a/packages/api/src/pulls/handler.spec.ts +++ b/packages/api/src/pulls/handler.spec.ts @@ -82,7 +82,13 @@ describe('createConversationPullRequestHandler', () => { token: 'ghp_secret', head: null, ttlMs: 30_000, - limits: { requestTimeoutMs: 10_000, lookupTimeoutMs: 30_000, maxCheckRunPages: 10 }, + limits: { + requestTimeoutMs: 10_000, + lookupTimeoutMs: 30_000, + maxCheckRunPages: 10, + maxCandidatePullRequests: 10, + maxHeadComparisons: 3, + }, }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ pullRequest: pr }); @@ -95,12 +101,20 @@ describe('createConversationPullRequestHandler', () => { requestTimeoutSeconds: 3, lookupTimeoutSeconds: 8, maxCheckRunPages: 4, + maxCandidatePullRequests: 25, + maxHeadComparisons: 6, }, }); await run(); expect(lookup).toHaveBeenCalledWith( expect.objectContaining({ - limits: { requestTimeoutMs: 3_000, lookupTimeoutMs: 8_000, maxCheckRunPages: 4 }, + limits: { + requestTimeoutMs: 3_000, + lookupTimeoutMs: 8_000, + maxCheckRunPages: 4, + maxCandidatePullRequests: 25, + maxHeadComparisons: 6, + }, }), ); }); @@ -155,15 +169,47 @@ describe('createConversationPullRequestHandler', () => { }); }); - it('answers null without reading anything when the feature is off', async () => { + it('answers null, and never touches GitHub, when the feature is off', async () => { for (const settings of [null, { enabled: false }]) { - const { run, res, getConvoLaneGit } = setup({ settings }); + const { run, res, lookup } = setup({ settings }); await run(); - expect(getConvoLaneGit).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith({ pullRequest: null }); } }); + it('starts the config and the owner-scoped lane reads together, not one after the other', async () => { + const order: string[] = []; + let releaseConfig: (config: unknown) => void = () => undefined; + const getAppConfig = jest.fn( + () => + new Promise((resolve) => { + order.push('config:start'); + releaseConfig = resolve; + }), + ); + const getConvoLaneGit = jest.fn(async () => { + order.push('lane:start'); + return { branch: 'feat/x', head: null, repo: 'o/r' }; + }); + const handler = createConversationPullRequestHandler({ + getConvoLaneGit, + getAppConfig: getAppConfig as never, + lookup: jest.fn().mockResolvedValue({ ok: true, value: pr }), + env: { GH_TOKEN: 'ghp_secret' }, + }); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + const done = handler( + { user: { id: 'u1' }, params: { conversationId: 'c1' } } as unknown as ServerRequest, + res as unknown as Response, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(order).toEqual(['config:start', 'lane:start']); + releaseConfig(configWith(enabled)); + await done; + expect(res.json).toHaveBeenCalledWith({ pullRequest: pr }); + }); + it.each([ ['no stored lane', null], ['a detached lane', { branch: null, head: null, repo: 'o/r' }], diff --git a/packages/api/src/pulls/handler.ts b/packages/api/src/pulls/handler.ts index b40d96fe11d..6af8bae2b9b 100644 --- a/packages/api/src/pulls/handler.ts +++ b/packages/api/src/pulls/handler.ts @@ -57,11 +57,19 @@ export function createConversationPullRequestHandler(deps: { return; } try { - const appConfig = await deps.getAppConfig({ - ...getAppConfigOptionsFromUser(req.user), - skipRuntimeAugmentation: true, - failClosed: true, - }); + /** + * Config and the owner-scoped lane are independent, so they load together. The lane is only + * used when the resolved config turns the feature on, which keeps a disabled deployment's + * answer the same as before. + */ + const [appConfig, laneGit] = await Promise.all([ + deps.getAppConfig({ + ...getAppConfigOptionsFromUser(req.user), + skipRuntimeAugmentation: true, + failClosed: true, + }), + deps.getConvoLaneGit(userId, conversationId), + ]); const settings = (appConfig.endpoints?.[EModelEndpoint.agents] as TAgentsEndpoint | undefined) ?.pullRequests; if (settings?.enabled !== true) { @@ -69,7 +77,6 @@ export function createConversationPullRequestHandler(deps: { return; } - const laneGit = await deps.getConvoLaneGit(userId, conversationId); if (laneGit?.branch == null || laneGit.repo == null) { res.status(200).json(NONE); return; @@ -100,6 +107,8 @@ export function createConversationPullRequestHandler(deps: { requestTimeoutMs: (settings.requestTimeoutSeconds ?? 10) * 1000, lookupTimeoutMs: (settings.lookupTimeoutSeconds ?? 30) * 1000, maxCheckRunPages: settings.maxCheckRunPages ?? 10, + maxCandidatePullRequests: settings.maxCandidatePullRequests ?? 10, + maxHeadComparisons: settings.maxHeadComparisons ?? 3, }, }); if (!result.ok) { diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index cf44dacf7f2..96fe50ea779 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -276,3 +276,16 @@ describe('lookup policy scoping', () => { expect(find).toHaveBeenCalledTimes(1); }); }); + +describe('lookup policy scoping of the history search', () => { + it.each([['maxCandidatePullRequests'], ['maxHeadComparisons']])( + 'does not share a result between callers with different %s', + async (field) => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, limits: { [field]: 3 } }); + await lookup({ ...input, limits: { [field]: 30 } }); + expect(find).toHaveBeenCalledTimes(2); + }, + ); +}); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index 77d5ce3566c..2fcc4370f8a 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -67,6 +67,8 @@ export function createPullRequestLookup({ limits?.requestTimeoutMs, limits?.lookupTimeoutMs, limits?.maxCheckRunPages, + limits?.maxCandidatePullRequests, + limits?.maxHeadComparisons, ].join(','); const key = `${scope}\0${repo}#${branch}\0${head ?? ''}\0${policy}`; const cached = entries.get(key); diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts index 6bf0f018008..51cf20d2346 100644 --- a/packages/api/src/pulls/types.ts +++ b/packages/api/src/pulls/types.ts @@ -28,6 +28,10 @@ export type PullRequestLookupLimits = { lookupTimeoutMs?: number; /** Most pages of check runs read before the rollup is reported as still running. */ maxCheckRunPages?: number; + /** Most pull requests listed per state when searching a branch's history for the match. */ + maxCandidatePullRequests?: number; + /** Most candidates compared with the recorded commit before the search gives up. */ + maxHeadComparisons?: number; }; export type PullRequestFindInput = { diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 3f66b5b06c1..d547ca91cac 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -1131,6 +1131,8 @@ describe('agent pull request config', () => { requestTimeoutSeconds: 10, lookupTimeoutSeconds: 30, maxCheckRunPages: 10, + maxCandidatePullRequests: 10, + maxHeadComparisons: 3, }); }); @@ -1179,14 +1181,26 @@ describe('agent pull request config', () => { ['maxCheckRunPages', 0], ['maxCheckRunPages', 51], ['maxCheckRunPages', 1.5], + ['maxCandidatePullRequests', 0], + ['maxCandidatePullRequests', 101], + ['maxHeadComparisons', -1], + ['maxHeadComparisons', 21], + ['maxHeadComparisons', 1.5], ])('rejects %s of %s', (field, value) => { expect(parse({ [field]: value }).success).toBe(false); }); it('accepts the documented bounds', () => { expect( - parse({ requestTimeoutSeconds: 60, lookupTimeoutSeconds: 120, maxCheckRunPages: 50 }).success, + parse({ + requestTimeoutSeconds: 60, + lookupTimeoutSeconds: 120, + maxCheckRunPages: 50, + maxCandidatePullRequests: 100, + maxHeadComparisons: 20, + }).success, ).toBe(true); + expect(parse({ maxHeadComparisons: 0 }).success).toBe(true); }); it.each([4, 3601, 1.5])('rejects a cache lifetime of %s seconds', (cacheTtlSeconds) => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 517ee92efda..763c769cf6f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1874,6 +1874,11 @@ export const agentsEndpointSchema = baseEndpointSchema lookupTimeoutSeconds: z.number().int().min(1).max(120).optional().default(30), /** Pages of 100 check runs read before the rollup is reported as still running. */ maxCheckRunPages: z.number().int().min(1).max(50).optional().default(10), + /** Pull requests listed per state when matching a branch's history to the commit a chat + * last ran at. Raise it for branch names that are reused many times. */ + maxCandidatePullRequests: z.number().int().min(1).max(100).optional().default(10), + /** Candidates compared with that commit before the search gives up. Each is one request. */ + maxHeadComparisons: z.number().int().min(0).max(20).optional().default(3), }) .superRefine((value, ctx) => { if (value.enabled && !value.token) { diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 3ff7f8c7a30..d1bd467e30f 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -9423,81 +9423,113 @@ describe('laneGit', () => { return conversationId; }; const head = 'a'.repeat(40); + /** A write with a sequence number drawn from the database, as the recorder draws one. */ + const record = async ( + input: Omit[0], 'seq'> & { seq?: number }, + ) => { + const seq = + input.seq ?? (await methods.reserveConvoLaneGitSeq(input.user, input.conversationId)); + if (seq == null) return false; + return methods.setConvoLaneGit({ ...input, seq }); + }; it('stores the reported branch and head for the owner and reads them back', async () => { const conversationId = await seed(); const laneGit = { branch: 'feat/pr-chip', head }; - await expect( - methods.setConvoLaneGit({ user: 'lane-user', conversationId, laneGit }), - ).resolves.toBe(true); + await expect(record({ user: 'lane-user', conversationId, laneGit })).resolves.toBe(true); await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual(laneGit); }); it('does not write for another owner or an unknown conversation', async () => { const conversationId = await seed(); const laneGit = { branch: 'main', head }; - await expect( - methods.setConvoLaneGit({ user: 'intruder', conversationId, laneGit }), - ).resolves.toBe(false); - await expect( - methods.setConvoLaneGit({ user: 'lane-user', conversationId: uuidv4(), laneGit }), - ).resolves.toBe(false); + await expect(record({ user: 'intruder', conversationId, laneGit })).resolves.toBe(false); + await expect(record({ user: 'lane-user', conversationId: uuidv4(), laneGit })).resolves.toBe( + false, + ); await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toBeNull(); await expect(methods.getConvoLaneGit('intruder', conversationId)).resolves.toBeNull(); }); - const at = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)); - - it('ignores a report older than the stored one, so a delayed write cannot win', async () => { + it('ignores a report with a lower sequence number, so a delayed write cannot win', async () => { const conversationId = await seed(); const user = 'lane-user'; + const first = await methods.reserveConvoLaneGitSeq(user, conversationId); + const second = await methods.reserveConvoLaneGitSeq(user, conversationId); const newer = { branch: 'newer', head }; const older = { branch: 'older', head: 'b'.repeat(40) }; await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit: newer, reportedAt: at(10) }), + methods.setConvoLaneGit({ user, conversationId, laneGit: newer, seq: second as number }), ).resolves.toBe(true); await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit: older, reportedAt: at(5) }), + methods.setConvoLaneGit({ user, conversationId, laneGit: older, seq: first as number }), ).resolves.toBe(false); await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(newer); }); - it('applies a report newer than the stored one, and keeps the newest of a repeated state', async () => { + it('issues increasing numbers per conversation and none for an unknown one', async () => { + const conversationId = await seed(); + const other = await seed(); + expect(await methods.reserveConvoLaneGitSeq('lane-user', conversationId)).toBe(1); + expect(await methods.reserveConvoLaneGitSeq('lane-user', conversationId)).toBe(2); + expect(await methods.reserveConvoLaneGitSeq('lane-user', other)).toBe(1); + expect(await methods.reserveConvoLaneGitSeq('intruder', conversationId)).toBeNull(); + expect(await methods.reserveConvoLaneGitSeq('lane-user', uuidv4())).toBeNull(); + }); + + it('issues distinct numbers to reservations made at the same time', async () => { + const conversationId = await seed(); + const numbers = await Promise.all( + Array.from({ length: 12 }, () => methods.reserveConvoLaneGitSeq('lane-user', conversationId)), + ); + expect(new Set(numbers).size).toBe(12); + expect(Math.min(...(numbers as number[]))).toBe(1); + expect(Math.max(...(numbers as number[]))).toBe(12); + }); + + it('is not skewed by the clocks of the processes that report', async () => { const conversationId = await seed(); const user = 'lane-user'; - const a = { branch: 'a', head }; - const b = { branch: 'b', head }; - await methods.setConvoLaneGit({ user, conversationId, laneGit: a, reportedAt: at(1) }); + const early = await methods.reserveConvoLaneGitSeq(user, conversationId); + const later = await methods.reserveConvoLaneGitSeq(user, conversationId); + /** An older report from a replica whose clock runs far ahead still carries the lower number. */ await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit: b, reportedAt: at(2) }), + methods.setConvoLaneGit({ + user, + conversationId, + laneGit: { branch: 'later', head }, + seq: later as number, + }), ).resolves.toBe(true); - await methods.setConvoLaneGit({ user, conversationId, laneGit: a, reportedAt: at(3) }); await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit: b, reportedAt: at(2.5) }), + methods.setConvoLaneGit({ + user, + conversationId, + laneGit: { branch: 'early-but-future-clock', head }, + seq: early as number, + }), ).resolves.toBe(false); - await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(a); + await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual({ + branch: 'later', + head, + }); }); - it('does not expose the report time through the read', async () => { + it('does not expose the sequence number or the counter through the read', async () => { const conversationId = await seed(); - await methods.setConvoLaneGit({ - user: 'lane-user', - conversationId, - laneGit: { branch: 'main', head }, - reportedAt: at(1), - }); + await record({ user: 'lane-user', conversationId, laneGit: { branch: 'main', head } }); const stored = await methods.getConvoLaneGit('lane-user', conversationId); - expect(stored).not.toHaveProperty('reportedAt'); + expect(stored).not.toHaveProperty('seq'); + const read = await Conversation.findOne({ conversationId }).lean(); + expect(read).not.toHaveProperty('laneGitSeq'); }); it('replaces the stored state when the branch or head changes', async () => { const conversationId = await seed(); const user = 'lane-user'; - await methods.setConvoLaneGit({ user, conversationId, laneGit: { branch: 'main', head } }); + await record({ user, conversationId, laneGit: { branch: 'main', head } }); const next = { branch: 'feat/x', head: 'b'.repeat(40) }; - await expect(methods.setConvoLaneGit({ user, conversationId, laneGit: next })).resolves.toBe( - true, - ); + await expect(record({ user, conversationId, laneGit: next })).resolves.toBe(true); await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual(next); }); @@ -9505,22 +9537,12 @@ describe('laneGit', () => { const conversationId = await seed(); const user = 'lane-user'; const laneGit = { branch: 'main', head }; - await expect( - methods.setConvoLaneGit({ user, conversationId, laneGit, repo: 'o/r', reportedAt: at(1) }), - ).resolves.toBe(true); + await expect(record({ user, conversationId, laneGit, repo: 'o/r' })).resolves.toBe(true); await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual({ ...laneGit, repo: 'o/r', }); - await expect( - methods.setConvoLaneGit({ - user, - conversationId, - laneGit, - repo: 'o/other', - reportedAt: at(2), - }), - ).resolves.toBe(true); + await expect(record({ user, conversationId, laneGit, repo: 'o/other' })).resolves.toBe(true); await expect(methods.getConvoLaneGit(user, conversationId)).resolves.toEqual({ ...laneGit, repo: 'o/other', @@ -9543,7 +9565,7 @@ describe('laneGit', () => { return conversationId; }; const write = (conversationId: string, workspace: Record) => - methods.setConvoLaneGit({ + record({ user: 'lane-user', conversationId, laneGit: { branch: 'feat/x', head }, @@ -9576,7 +9598,7 @@ describe('laneGit', () => { const conversationId = await seedRaw({ codeEnvironmentMode: 'attached', codeWorkspaces: [mac], - laneGit: { branch: 'old', head, repo: 'o/old', reportedAt: new Date(0) }, + laneGit: { branch: 'old', head, repo: 'o/old', seq: 1 }, }); const stored = await methods.getConvoCodeEnvironmentDecision('lane-user', conversationId); await methods.replaceConvoCodeEnvironmentDecision({ @@ -9616,9 +9638,7 @@ describe('laneGit', () => { it('keeps a detached or empty lane distinct from an unknown one', async () => { const conversationId = await seed(); const laneGit = { branch: null, head: null }; - await expect( - methods.setConvoLaneGit({ user: 'lane-user', conversationId, laneGit }), - ).resolves.toBe(true); + await expect(record({ user: 'lane-user', conversationId, laneGit })).resolves.toBe(true); await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual(laneGit); }); @@ -9626,7 +9646,7 @@ describe('laneGit', () => { const kept = { branch: 'kept', head }; const seedWithLane = async () => { const conversationId = await seed(); - await methods.setConvoLaneGit({ user: 'lane-user', conversationId, laneGit: kept }); + await record({ user: 'lane-user', conversationId, laneGit: kept }); return conversationId; }; @@ -9658,6 +9678,25 @@ describe('laneGit', () => { await expect(methods.getConvoLaneGit('lane-user', imported)).resolves.toBeNull(); }); + it('keeps the sequence counter out of generic saves, unset fields and imports', async () => { + const conversationId = await seedWithLane(); + const before = await methods.reserveConvoLaneGitSeq('lane-user', conversationId); + await saveConvo({ userId: 'lane-user' }, { conversationId, laneGitSeq: 0 }); + await saveConvo( + { userId: 'lane-user' }, + { conversationId }, + { unsetFields: { laneGitSeq: 1 } }, + ); + const imported = uuidv4(); + await methods.bulkSaveConvos([ + { conversationId: imported, user: 'lane-user', title: 'Imported', laneGitSeq: 99 }, + ]); + expect(await methods.reserveConvoLaneGitSeq('lane-user', conversationId)).toBe( + (before as number) + 1, + ); + expect(await methods.reserveConvoLaneGitSeq('lane-user', imported)).toBe(1); + }); + it('is excluded from ordinary conversation reads', async () => { const conversationId = await seedWithLane(); const read = await Conversation.findOne({ conversationId }).lean(); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index ea11e25388b..6b5d89f59f2 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -329,20 +329,21 @@ export interface ConversationMethods { IConversation, 'conversationId' | 'codeEnvironmentMode' | 'codeWorkspaces' | 'codeEnvironmentRevision' > | null>; + reserveConvoLaneGitSeq(user: string, conversationId: string): Promise; setConvoLaneGit(input: { user: string; conversationId: string; laneGit: Pick, 'branch' | 'head'>; repo?: string; - /** When the worker reported this state; defaults to now. */ - reportedAt?: Date; + /** Reserved with `reserveConvoLaneGitSeq` when the command settled. */ + seq: number; /** The workspace the command ran in; the write applies only while the chat is still on it. */ workspace?: { environmentId: string; workspaceId: string; required?: boolean }; }): Promise; getConvoLaneGit( user: string, conversationId: string, - ): Promise, 'reportedAt'> | null>; + ): Promise, 'seq'> | null>; addConvoToolApprovalAllows(input: { user: string; conversationId: string; @@ -2467,6 +2468,7 @@ export function createConversationMethods( /* Remembered tool approvals are granted only by a validated resume. */ delete update.toolApprovalAllows; delete update.laneGit; + delete update.laneGitSeq; /** Ordinary saves may seed a decision, but only an explicit move may replace it. */ const decisionOnInsert = { ...(convo.codeEnvironmentMode != null && { @@ -2493,6 +2495,7 @@ export function createConversationMethods( delete unsetFields.titleRevision; delete unsetFields.toolApprovalAllows; delete unsetFields.laneGit; + delete unsetFields.laneGitSeq; delete unsetFields.codeEnvironmentRevision; delete unsetFields.codeEnvironmentMode; delete unsetFields.codeWorkspaces; @@ -3019,31 +3022,54 @@ export function createConversationMethods( return result.matchedCount === 1; } + /** + * Reserve the next lane report sequence number for an owner's conversation. The counter lives in + * the database, so every replica draws from one order that no process clock can skew, and the + * number is taken when a command settles, before its write is attempted. Resolves to null when + * the conversation does not exist yet for this owner. + */ + async function reserveConvoLaneGitSeq( + user: string, + conversationId: string, + ): Promise { + const Conversation = mongoose.models.Conversation as Model; + const reserved = await withoutMeiliIndexing( + Conversation.findOneAndUpdate( + { user, conversationId }, + { $inc: { laneGitSeq: 1 } }, + { new: true, timestamps: false }, + ), + ) + .select('laneGitSeq') + .lean | null>(); + return reserved?.laneGitSeq ?? null; + } + /** * Record the branch and head a conversation's code lane last reported. Owner-scoped and fenced - * by report time: the write matches only while the stored report is not newer than this one, so - * a delayed older report, from this process or another replica, can never replace a newer - * state. When the caller names the workspace the command ran in, the write also matches only - * while the conversation is still attached to it, so a report queued before a move or detach - * cannot bring the old workspace's lane back. A conversation with no stored workspace (an agent - * default) accepts the report unless the caller requires a recorded one. Server-written only: - * generic saves and imports cannot set it. Resolves to whether the write applied; false means a - * newer report is already stored, the workspace no longer matches, or there is no such - * conversation for this owner. + * by the sequence number reserved when the command settled: the write matches only while the + * stored report carries a lower one, so a delayed older report, from this process or another + * replica, can never replace a newer state. When the caller names the workspace the command ran + * in, the write also matches only while the conversation is still attached to it, so a report + * queued before a move or detach cannot bring the old workspace's lane back. A conversation + * with no stored workspace (an agent default) accepts the report unless the caller requires a + * recorded one. Server-written only: generic saves and imports cannot set it. Resolves to + * whether the write applied; false means a newer report is already stored, the workspace no + * longer matches, or there is no such conversation for this owner. */ async function setConvoLaneGit({ user, conversationId, laneGit, repo, - reportedAt = new Date(), + seq, workspace, }: { user: string; conversationId: string; laneGit: Pick, 'branch' | 'head'>; repo?: string; - reportedAt?: Date; + seq: number; workspace?: { environmentId: string; workspaceId: string; required?: boolean }; }): Promise { const Conversation = mongoose.models.Conversation as Model; @@ -3051,7 +3077,7 @@ export function createConversationMethods( branch: laneGit.branch, head: laneGit.head, ...(repo ? { repo } : {}), - reportedAt, + seq, }; const workspaceFence = workspace == null @@ -3090,9 +3116,9 @@ export function createConversationMethods( $and: [ { $or: [ - { 'laneGit.reportedAt': { $exists: false } }, - { 'laneGit.reportedAt': null }, - { 'laneGit.reportedAt': { $lte: reportedAt } }, + { 'laneGit.seq': { $exists: false } }, + { 'laneGit.seq': null }, + { 'laneGit.seq': { $lt: seq } }, ], }, ...workspaceFence, @@ -3109,7 +3135,7 @@ export function createConversationMethods( async function getConvoLaneGit( user: string, conversationId: string, - ): Promise, 'reportedAt'> | null> { + ): Promise, 'seq'> | null> { const Conversation = mongoose.models.Conversation as Model; const stored = await Conversation.findOne({ user, conversationId }) .select('laneGit') @@ -3272,6 +3298,7 @@ export function createConversationMethods( delete sanitized.initial_agent_id; delete sanitized.toolApprovalAllows; delete sanitized.laneGit; + delete sanitized.laneGitSeq; delete sanitized.codeEnvironmentRevision; stripActorCheckpointFields(sanitized); if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') { @@ -4354,6 +4381,7 @@ export function createConversationMethods( appendConvoMessageReference, getConvoCodeEnvironmentDecision, addConvoToolApprovalAllows, + reserveConvoLaneGitSeq, setConvoLaneGit, getConvoLaneGit, readAdmittedConvoCodeEnvironmentDecision, diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index 1f7872ca577..edf3dc534cb 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -54,13 +54,16 @@ const convoSchema: Schema = new Schema( branch: { type: String, default: null }, head: { type: String, default: null }, repo: { type: String, default: undefined }, - /** When the worker reported this state; fences out an older report that lands later. */ - reportedAt: { type: Date, default: undefined }, + /** Sequence number reserved when the command settled; fences out an older report. */ + seq: { type: Number, default: undefined }, }, _id: false, default: undefined, select: false, }, + /** Counter that issues `laneGit.seq`, shared by every replica. Server-written only, through + * `reserveConvoLaneGitSeq`, and excluded from ordinary conversation reads. */ + laneGitSeq: { type: Number, default: undefined, select: false }, agent_id: { type: String, }, diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index 8cbeb3e375c..b7fa4f8f051 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -27,8 +27,8 @@ export interface IConversationLaneGit { head: string | null; /** `owner/name` of the lane's repository, as its worker registered it. */ repo?: string; - /** When the state was reported; internal, never returned by `getConvoLaneGit`. */ - reportedAt?: Date; + /** Reserved sequence number; internal, never returned by `getConvoLaneGit`. */ + seq?: number; } export interface ISubagentThreadLease { @@ -302,6 +302,8 @@ export interface IConversation extends Document { toolApprovalAllows?: string[]; /** Last lane branch and head a command reported; server-written only and excluded from reads. */ laneGit?: IConversationLaneGit; + /** Counter behind `laneGit.seq`; server-written only and excluded from reads. */ + laneGitSeq?: number; /** Immutable primary persisted-agent attribution for Insights. */ initial_agent_id?: string | null; subagentThread?: TSubagentThreadLineage; From 15cdb7af618ddf59d8a8300a53327f6962f47f86 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:23:40 +0200 Subject: [PATCH 06/10] fix: Queue Lane Reports by the Visible Conversation and Read Checks for the Current Head Order lane report reservations by the conversation the write lands on, so two subagent threads of one conversation share a chain, and write every report instead of skipping a repeat, since another recorder may have changed the lane in between. Read a pull request's detail before its check runs and use the head it reports, so line counts, mergeability and checks describe one commit. Treat an expired temporary chat as gone when reserving, writing and reading the lane, and make the shared cache capacity a setting. --- librechat.example.yaml | 3 + packages/api/src/code/lane.spec.ts | 86 +++++++++++++++++-- packages/api/src/code/lane.ts | 65 ++++++++------ packages/api/src/pulls/github.spec.ts | 44 ++++++++++ packages/api/src/pulls/github.ts | 24 ++++-- packages/api/src/pulls/handler.spec.ts | 3 + packages/api/src/pulls/handler.ts | 1 + packages/api/src/pulls/lookup.spec.ts | 37 ++++++++ packages/api/src/pulls/lookup.ts | 13 ++- packages/api/src/pulls/types.ts | 6 +- packages/data-provider/src/config.spec.ts | 5 ++ packages/data-provider/src/config.ts | 4 + .../src/methods/conversation.spec.ts | 58 +++++++++++++ .../data-schemas/src/methods/conversation.ts | 17 ++-- 14 files changed, 315 insertions(+), 51 deletions(-) diff --git a/librechat.example.yaml b/librechat.example.yaml index fff940160f9..f99408f12f4 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -938,6 +938,9 @@ endpoints: # allowedRepositories: ["LibreChat-AI/LibreChat"] # # Seconds a looked-up pull request is reused before GitHub is asked again (5 to 3600). # cacheTtlSeconds: 30 + # # Results the shared cache holds before it evicts the oldest (10 to 100000). Raise it on a busy + # # multi-tenant server, or fresh results are evicted and GitHub is asked again. + # cacheMaxEntries: 500 # # Bounds on one lookup. A repository with many check runs reads one page of 100 per request. # requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60) # lookupTimeoutSeconds: 30 # longest the whole lookup may take (1 to 120) diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index 1ea821f636b..0788f0e5676 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -297,7 +297,7 @@ describe('createLaneGitRecorder ordering', () => { expect(setConvoLaneGit).toHaveBeenCalledTimes(2); }); - it('skips a report identical to the last one it wrote', async () => { + it('does not skip a report that repeats the last one, since another writer may have changed the lane', async () => { const reserve = counter(); const setConvoLaneGit = jest.fn().mockResolvedValue(true); const record = createLaneGitRecorder({ @@ -306,11 +306,34 @@ describe('createLaneGitRecorder ordering', () => { reserveConvoLaneGitSeq: reserve, setConvoLaneGit, }); - await expect(record?.(laneGit)).resolves.toBe(true); - await expect(record?.(laneGit)).resolves.toBe(false); - await expect(record?.(laneGit)).resolves.toBe(false); - expect(reserve).toHaveBeenCalledTimes(1); - expect(setConvoLaneGit).toHaveBeenCalledTimes(1); + await record?.(laneGit); + await record?.(laneGit); + expect(reserve).toHaveBeenCalledTimes(2); + expect(setConvoLaneGit).toHaveBeenCalledTimes(2); + }); + + it('lets a recorder restore its state after another recorder changed the same conversation', async () => { + const written: string[] = []; + const setConvoLaneGit = jest.fn( + async ({ laneGit: reported }: { laneGit: { branch: string | null } }) => { + written.push(String(reported.branch)); + return true; + }, + ); + const reserve = counter(); + const make = () => + createLaneGitRecorder({ + user: 'u1', + conversationId: 'order-shared', + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit, + }); + const first = make(); + const second = make(); + await first?.({ branch: 'x', head }); + await second?.({ branch: 'y', head }); + await first?.({ branch: 'x', head }); + expect(written).toEqual(['x', 'y', 'x']); }); it('writes again once the state changes, including back to an earlier one', async () => { @@ -330,6 +353,57 @@ describe('createLaneGitRecorder ordering', () => { 'a', ]); }); + + it('orders reservations by the visible conversation two subagent threads share', async () => { + const releases: Array<() => void> = []; + const reserve = jest.fn( + () => new Promise((resolve) => releases.push(() => resolve(releases.length))), + ); + const getConvoOwnership = jest + .fn() + .mockResolvedValue({ subagentThread: { rootConversationId: 'shared-root' } }); + const make = (conversationId: string) => + createLaneGitRecorder({ + user: 'u1', + conversationId, + workspace: { environmentId: 'code-mac', workspaceId: 'primary' }, + getConvoOwnership, + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit: jest.fn().mockResolvedValue(true), + }); + void make('child-a')?.({ branch: 'a', head }); + void make('child-b')?.({ branch: 'b', head }); + await flush(); + expect(reserve).toHaveBeenCalledTimes(1); + releases[0](); + await flush(); + expect(reserve).toHaveBeenCalledTimes(2); + releases[1](); + }); + + it('does not make threads of different visible conversations wait for each other', async () => { + const releases: Array<() => void> = []; + const reserve = jest.fn( + () => new Promise((resolve) => releases.push(() => resolve(1))), + ); + const getConvoOwnership = jest.fn(async (_user: string, id: string) => ({ + subagentThread: { rootConversationId: id === 'child-a' ? 'root-a' : 'root-b' }, + })); + const make = (conversationId: string) => + createLaneGitRecorder({ + user: 'u1', + conversationId, + workspace: { environmentId: 'code-mac', workspaceId: 'primary' }, + getConvoOwnership, + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit: jest.fn().mockResolvedValue(true), + }); + void make('child-a')?.({ branch: 'a', head }); + void make('child-b')?.({ branch: 'b', head }); + await flush(); + expect(reserve).toHaveBeenCalledTimes(2); + releases.forEach((release) => release()); + }); }); describe('createLaneGitRecorder target conversation', () => { diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index f262d4f941d..81ec8bb4b69 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -38,11 +38,13 @@ type Placement = { conversationId: string; required: boolean }; type Reservation = Placement & { seq: number }; /** - * The tail of each conversation's pending sequence reservations in this process. Numbers are - * drawn one at a time in report order, so one process never takes a lower number for a later - * report. Writes are not queued: each carries its number and the database ignores a lower one, - * so a slow or reordered write cannot replace a newer state, here or on another replica. Entries - * leave the map as soon as their chain drains. + * The tail of each visible conversation's pending sequence reservations in this process, keyed by + * the conversation the write lands on (the root of a subagent thread, not the thread). Two + * threads of one conversation therefore share a chain, and numbers are drawn one at a time in + * report order, so one process never takes a lower number for a later report. Writes are not + * queued: each carries its number and the database ignores a lower one, so a slow or reordered + * write cannot replace a newer state, here or on another replica. Entries leave the map as soon + * as their chain drains. */ const reservationTails = new Map>(); @@ -51,10 +53,12 @@ const reservationTails = new Map>(); * throws and is never awaited by the command: the branch is a header affordance, so a database * outage must not fail or delay the agent's tool call. Each report takes a sequence number from * the database when it arrives and is written with it, so the newest report wins whatever order - * the writes land in. A report identical to the last one this recorder wrote is skipped. Resolves - * to whether the write applied, which is false when a newer report is already stored, for a - * conversation not saved yet, and for a skipped repeat. A repo that is not a plain `owner/name` - * is dropped rather than stored. + * the writes land in. Every report is written, repeats included, because another recorder (a + * sibling subagent thread, another replica) may have changed the lane since this one last wrote, + * and the database already ignores a write that changes nothing the reader can see. Resolves to + * whether the write applied, which is false when a newer report is already stored and for a + * conversation not saved yet. A repo that is not a plain `owner/name` is dropped rather than + * stored. */ export function createLaneGitRecorder({ user, @@ -75,7 +79,6 @@ export function createLaneGitRecorder({ }): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { if (!user || !conversationId) return undefined; const safeRepo = isSafeRepo(repo) ? repo : undefined; - const queueKey = `${user}\0${conversationId}`; /** * A subagent thread is a hidden child of the conversation the user sees, and its commands run on @@ -133,24 +136,30 @@ export function createLaneGitRecorder({ } }; - /** The state this recorder last wrote, so a command that changed nothing costs no database work. */ - let lastWritten: string | undefined; - return (laneGit) => { - const state = JSON.stringify([laneGit.branch, laneGit.head]); - if (state === lastWritten) return Promise.resolve(false); - - const previous = reservationTails.get(queueKey) ?? Promise.resolve(); - const reservation = previous.then(() => reserve()); - reservationTails.set(queueKey, reservation); - void reservation.then(() => { - if (reservationTails.get(queueKey) === reservation) reservationTails.delete(queueKey); - }); - return reservation.then(async (reserved) => { - if (reserved == null) return false; - const applied = await write(laneGit, reserved); - if (applied) lastWritten = state; - return applied; - }); + /** + * The chain is chosen after the target is resolved, so threads that share a visible + * conversation share it. Resolution is memoised and idempotent, so waiting on it here costs + * one lookup per recorder. + */ + const run = async (): Promise => { + let placed: Placement; + try { + placed = await resolveTarget(); + } catch (error) { + logger.warn('[LaneGit] Failed to place a lane report', getSafeErrorMetadata(error)); + return false; + } + const queueKey = `${user}\0${placed.conversationId}`; + const previous = reservationTails.get(queueKey) ?? Promise.resolve(); + const reservation = previous.then(() => reserve()); + reservationTails.set(queueKey, reservation); + void reservation.then(() => { + if (reservationTails.get(queueKey) === reservation) reservationTails.delete(queueKey); + }); + const reserved = await reservation; + return reserved == null ? false : write(laneGit, reserved); + }; + return run(); }; } diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index 183886c4b88..37582ce0e64 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -12,6 +12,7 @@ const pull = (overrides: Record = {}) => ({ additions: 12, deletions: 3, mergeable: true, + head: { sha }, ...overrides, }); const listed = (state = 'open') => [{ number: 7, state, head: { sha } }]; @@ -495,3 +496,46 @@ describe('matching the recorded head', () => { expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); }); }); + +describe('check runs follow the current head of the pull request', () => { + const newer = 'd'.repeat(40); + + it('reads checks for the head the detail reports, not the one the list saw', async () => { + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json(listed()), + '/pulls/7': () => json(pull({ head: { sha: newer } })), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + await expect(find(source)).resolves.toMatchObject({ number: 7 }); + const urls = fetchFn.mock.calls.map(([url]) => String(url)); + expect(urls.some((url) => url.includes(`/commits/${newer}/check-runs`))).toBe(true); + expect(urls.some((url) => url.includes(`/commits/${sha}/check-runs`))).toBe(false); + }); + + it('reads the detail before the checks, so they describe the same commit', async () => { + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json(listed()), + '/pulls/7': () => json(pull()), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + await find(source); + const urls = fetchFn.mock.calls.map(([url]) => String(url)); + expect(urls.findIndex((url) => url.includes('/pulls/7'))).toBeLessThan( + urls.findIndex((url) => url.includes('/check-runs')), + ); + }); + + it.each([ + ['a missing head', { head: undefined }], + ['a head without a sha', { head: {} }], + ['a head that is not a commit id', { head: { sha: '../../x' } }], + ])('rejects a pull request detail with %s', async (_label, overrides) => { + const { source, fetchFn } = sourceFor({ + '/pulls?state=open': () => json(listed()), + '/pulls/7': () => json(pull(overrides)), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + await expect(find(source)).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/check-runs'))).toBe(false); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index c1e803096aa..6c0708fd9c8 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -46,6 +46,8 @@ type GitHubPull = { additions: number; deletions: number; mergeable: boolean | null; + /** The head this detail describes; the check runs are read for this same commit. */ + headSha: string; }; type GitHubCheckRun = { status: string; conclusion: string | null }; @@ -78,7 +80,10 @@ function parsePull(value: unknown): GitHubPull { typeof value.draft !== 'boolean' || !isCount(value.additions) || !isCount(value.deletions) || - (value.mergeable !== null && typeof value.mergeable !== 'boolean') + (value.mergeable !== null && typeof value.mergeable !== 'boolean') || + !isRecord(value.head) || + typeof value.head.sha !== 'string' || + !COMMIT_ID.test(value.head.sha) ) { throw new PullRequestSourceError('UPSTREAM_ERROR'); } @@ -92,6 +97,7 @@ function parsePull(value: unknown): GitHubPull { additions: value.additions, deletions: value.deletions, mergeable: value.mergeable, + headSha: value.head.sha, }; } @@ -337,12 +343,16 @@ export function createGitHubPullRequestSource({ )); if (chosen == null) return null; - const [pull, checks] = await Promise.all([ - getJson(`${base}/pulls/${chosen.number}`, lookup), - readCheckRuns(`${base}/commits/${chosen.sha}/check-runs`, lookup), - ]); - if (pull == null) return null; - return toConversationPullRequest(parsePull(pull), checks.runs, checks.incomplete); + /** + * The detail comes first and its head decides which commit's checks are read. Listing, + * detail and checks are separate requests, so a push between them could otherwise pair + * today's line counts and mergeability with yesterday's checks. + */ + const detail = await getJson(`${base}/pulls/${chosen.number}`, lookup); + if (detail == null) return null; + const pull = parsePull(detail); + const checks = await readCheckRuns(`${base}/commits/${pull.headSha}/check-runs`, lookup); + return toConversationPullRequest(pull, checks.runs, checks.incomplete); }, }; } diff --git a/packages/api/src/pulls/handler.spec.ts b/packages/api/src/pulls/handler.spec.ts index 7657f901e27..bb2cb6df743 100644 --- a/packages/api/src/pulls/handler.spec.ts +++ b/packages/api/src/pulls/handler.spec.ts @@ -82,6 +82,7 @@ describe('createConversationPullRequestHandler', () => { token: 'ghp_secret', head: null, ttlMs: 30_000, + cacheMaxEntries: 500, limits: { requestTimeoutMs: 10_000, lookupTimeoutMs: 30_000, @@ -103,11 +104,13 @@ describe('createConversationPullRequestHandler', () => { maxCheckRunPages: 4, maxCandidatePullRequests: 25, maxHeadComparisons: 6, + cacheMaxEntries: 77, }, }); await run(); expect(lookup).toHaveBeenCalledWith( expect.objectContaining({ + cacheMaxEntries: 77, limits: { requestTimeoutMs: 3_000, lookupTimeoutMs: 8_000, diff --git a/packages/api/src/pulls/handler.ts b/packages/api/src/pulls/handler.ts index 6af8bae2b9b..a0c0a82c881 100644 --- a/packages/api/src/pulls/handler.ts +++ b/packages/api/src/pulls/handler.ts @@ -103,6 +103,7 @@ export function createConversationPullRequestHandler(deps: { head: laneGit.head, token, ttlMs: (settings.cacheTtlSeconds ?? 30) * 1000, + cacheMaxEntries: settings.cacheMaxEntries ?? 500, limits: { requestTimeoutMs: (settings.requestTimeoutSeconds ?? 10) * 1000, lookupTimeoutMs: (settings.lookupTimeoutSeconds ?? 30) * 1000, diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index 96fe50ea779..5d346e64c27 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -289,3 +289,40 @@ describe('lookup policy scoping of the history search', () => { }, ); }); + +describe('cache capacity from the caller', () => { + const branches = ['a', 'b', 'c']; + const fill = async ( + lookup: ReturnType, + cacheMaxEntries?: number, + ) => { + for (const branch of branches) await lookup({ ...input, branch, cacheMaxEntries }); + await lookup({ ...input, branch: 'a', cacheMaxEntries }); + }; + + it('evicts fresh entries once the configured capacity is exceeded', async () => { + const find = jest.fn().mockResolvedValue(value); + await fill(createPullRequestLookup({ source: { find } }), 2); + expect(find).toHaveBeenCalledTimes(4); + }); + + it('keeps them while the configured capacity is large enough', async () => { + const find = jest.fn().mockResolvedValue(value); + await fill(createPullRequestLookup({ source: { find } }), 10); + expect(find).toHaveBeenCalledTimes(3); + }); + + it('falls back to its own default capacity when the caller gives none', async () => { + const find = jest.fn().mockResolvedValue(value); + await fill(createPullRequestLookup({ source: { find } })); + expect(find).toHaveBeenCalledTimes(3); + }); + + it('does not treat the capacity as part of the answer, so callers with different ones share an entry', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, cacheMaxEntries: 5 }); + await lookup({ ...input, cacheMaxEntries: 50 }); + expect(find).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index 2fcc4370f8a..94697e75a40 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -40,11 +40,16 @@ export function createPullRequestLookup({ const cooldowns = new Map(); const rateLimited: PullRequestLookupResult = { ok: false, error: { code: 'RATE_LIMITED' } }; - function remember(key: string, result: PullRequestLookupResult, ttlMs: number): void { + function remember( + key: string, + result: PullRequestLookupResult, + ttlMs: number, + capacity: number, + ): void { const lifetime = result.ok ? ttlMs : Math.min(ttlMs, FAILURE_TTL_MS); entries.delete(key); entries.set(key, { result, expiresAt: now() + lifetime }); - while (entries.size > maxEntries) { + while (entries.size > capacity) { const oldest = entries.keys().next(); if (oldest.done) break; entries.delete(oldest.value); @@ -59,7 +64,7 @@ export function createPullRequestLookup({ } } - return async ({ repo, branch, head, token, ttlMs, limits }) => { + return async ({ repo, branch, head, token, ttlMs, limits, cacheMaxEntries }) => { const scope = scopeOf(token); /** Everything that changes the answer or how long it may be reused is part of the key. */ const policy = [ @@ -95,7 +100,7 @@ export function createPullRequestLookup({ } result = { ok: false, error: { code } }; } - remember(key, result, ttlMs); + remember(key, result, ttlMs, cacheMaxEntries ?? maxEntries); return result; })().finally(() => inflight.delete(key)); inflight.set(key, run); diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts index 51cf20d2346..45c57dadac1 100644 --- a/packages/api/src/pulls/types.ts +++ b/packages/api/src/pulls/types.ts @@ -53,6 +53,10 @@ export type PullRequestLookupResult = | { ok: true; value: TConversationPullRequest | null } | { ok: false; error: { code: PullRequestErrorCode } }; -export type PullRequestLookupInput = PullRequestFindInput & { ttlMs: number }; +export type PullRequestLookupInput = PullRequestFindInput & { + ttlMs: number; + /** Entries the shared cache holds before it evicts the oldest; the lookup's own default if omitted. */ + cacheMaxEntries?: number; +}; export type PullRequestLookup = (input: PullRequestLookupInput) => Promise; diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index d547ca91cac..539d129d1c9 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -1133,6 +1133,7 @@ describe('agent pull request config', () => { maxCheckRunPages: 10, maxCandidatePullRequests: 10, maxHeadComparisons: 3, + cacheMaxEntries: 500, }); }); @@ -1186,6 +1187,9 @@ describe('agent pull request config', () => { ['maxHeadComparisons', -1], ['maxHeadComparisons', 21], ['maxHeadComparisons', 1.5], + ['cacheMaxEntries', 9], + ['cacheMaxEntries', 100_001], + ['cacheMaxEntries', 1.5], ])('rejects %s of %s', (field, value) => { expect(parse({ [field]: value }).success).toBe(false); }); @@ -1198,6 +1202,7 @@ describe('agent pull request config', () => { maxCheckRunPages: 50, maxCandidatePullRequests: 100, maxHeadComparisons: 20, + cacheMaxEntries: 100_000, }).success, ).toBe(true); expect(parse({ maxHeadComparisons: 0 }).success).toBe(true); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 763c769cf6f..a15c7557a8b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1868,6 +1868,10 @@ export const agentsEndpointSchema = baseEndpointSchema allowedRepositories: z.array(pullRequestRepositorySchema).max(256).optional(), /** Seconds a looked-up pull request is reused before GitHub is asked again. */ cacheTtlSeconds: z.number().int().min(5).max(3600).optional().default(30), + /** Pull requests the shared cache holds before it evicts the oldest. Size it to the + * distinct credential, repository and branch combinations seen within one cache lifetime, + * or fresh results are evicted and GitHub is asked again. */ + cacheMaxEntries: z.number().int().min(10).max(100_000).optional().default(500), /** Longest one GitHub request may take. Raise it behind a slow proxy. */ requestTimeoutSeconds: z.number().int().min(1).max(60).optional().default(10), /** Longest a whole lookup, every request together, may hold the header request. */ diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index d1bd467e30f..f06d0b6cfe2 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -9635,6 +9635,64 @@ describe('laneGit', () => { }); }); + describe('retention', () => { + const lane = { branch: 'feat/x', head, seq: 1 }; + const seedExpiry = async (expiredAt: Date | null) => { + const conversationId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId, + user: 'lane-user', + title: 'Retention', + endpoint: 'agents', + messages: [], + laneGit: lane, + laneGitSeq: 1, + expiredAt, + }); + return conversationId; + }; + const past = () => new Date(Date.now() - 60_000); + const future = () => new Date(Date.now() + 3_600_000); + + it('does not serve the lane of an expired temporary chat', async () => { + const conversationId = await seedExpiry(past()); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toBeNull(); + }); + + it('does not reserve a number for, or write to, an expired temporary chat', async () => { + const conversationId = await seedExpiry(past()); + await expect(methods.reserveConvoLaneGitSeq('lane-user', conversationId)).resolves.toBeNull(); + await expect( + methods.setConvoLaneGit({ + user: 'lane-user', + conversationId, + laneGit: { branch: 'later', head }, + seq: 99, + }), + ).resolves.toBe(false); + }); + + it.each([ + ['no expiry', null], + ['an expiry in the future', future()], + ])('still serves, reserves for and writes to a chat with %s', async (_label, expiredAt) => { + const conversationId = await seedExpiry(expiredAt); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toMatchObject({ + branch: 'feat/x', + }); + const seq = await methods.reserveConvoLaneGitSeq('lane-user', conversationId); + expect(seq).toBe(2); + await expect( + methods.setConvoLaneGit({ + user: 'lane-user', + conversationId, + laneGit: { branch: 'later', head }, + seq: seq as number, + }), + ).resolves.toBe(true); + }); + }); + it('keeps a detached or empty lane distinct from an unknown one', async () => { const conversationId = await seed(); const laneGit = { branch: null, head: null }; diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 6b5d89f59f2..e0a38d31975 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -3035,7 +3035,7 @@ export function createConversationMethods( const Conversation = mongoose.models.Conversation as Model; const reserved = await withoutMeiliIndexing( Conversation.findOneAndUpdate( - { user, conversationId }, + { user, conversationId, ...activeExpirationFilter() }, { $inc: { laneGitSeq: 1 } }, { new: true, timestamps: false }, ), @@ -3053,9 +3053,11 @@ export function createConversationMethods( * in, the write also matches only while the conversation is still attached to it, so a report * queued before a move or detach cannot bring the old workspace's lane back. A conversation * with no stored workspace (an agent default) accepts the report unless the caller requires a - * recorded one. Server-written only: generic saves and imports cannot set it. Resolves to - * whether the write applied; false means a newer report is already stored, the workspace no - * longer matches, or there is no such conversation for this owner. + * recorded one. Server-written only: generic saves and imports cannot set it. An expired + * temporary chat is treated as gone, here and in the reservation and the read, the way every + * other owner-scoped conversation access treats it. Resolves to whether the write applied; false + * means a newer report is already stored, the workspace no longer matches, or there is no such + * conversation for this owner. */ async function setConvoLaneGit({ user, @@ -3113,6 +3115,7 @@ export function createConversationMethods( { user, conversationId, + ...activeExpirationFilter(), $and: [ { $or: [ @@ -3137,7 +3140,11 @@ export function createConversationMethods( conversationId: string, ): Promise, 'seq'> | null> { const Conversation = mongoose.models.Conversation as Model; - const stored = await Conversation.findOne({ user, conversationId }) + const stored = await Conversation.findOne({ + user, + conversationId, + ...activeExpirationFilter(), + }) .select('laneGit') .lean | null>(); if (stored?.laneGit == null) return null; From 517f4ec964534f6d23a38c209a05d6d3afdcd61e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:37:37 +0200 Subject: [PATCH 07/10] fix: Fence Lane Reports by Attachment Epoch and Place Recorders Before Reporting Strip dotted descendants of lane fields from saves and imports, partition the pull request cache per credential, resolve the lane write target when the tool is created, and fence reports with a codeAttachmentEpoch that advances only on an owner move or detach. --- api/server/services/ToolService.js | 6 +- .../services/__tests__/ToolService.spec.js | 6 +- librechat.example.yaml | 4 +- packages/api/src/code/lane.spec.ts | 149 ++++++++++++---- packages/api/src/code/lane.ts | 142 +++++++-------- packages/api/src/pulls/lookup.spec.ts | 26 +++ packages/api/src/pulls/lookup.ts | 32 +++- packages/data-provider/src/config.ts | 6 +- .../src/methods/conversation.spec.ts | 164 ++++++++++++++++++ .../data-schemas/src/methods/conversation.ts | 76 ++++++-- packages/data-schemas/src/schema/convo.ts | 4 + packages/data-schemas/src/types/convo.ts | 2 + 12 files changed, 486 insertions(+), 131 deletions(-) diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index f368a6bd9ad..27347feb3ea 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -135,7 +135,7 @@ const { findPluginAuthsByKeys, getRoleByName, setConvoLaneGit, - getConvoOwnership, + getConvoLaneContext, reserveConvoLaneGitSeq, } = require('~/models'); const { getFlowStateManager, getMCPServersRegistry } = require('~/config'); @@ -2393,7 +2393,7 @@ async function loadToolsForExecution({ baseUrl: codeExecutionContext.baseUrl, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId, - onLaneGit: createLaneGitRecorder({ + onLaneGit: await createLaneGitRecorder({ user: req.user.id, conversationId: conversationId ?? runtimeRequestBody?.conversationId, repo: codeExecutionContext.codeWorkspace.environment?.repo, @@ -2401,7 +2401,7 @@ async function loadToolsForExecution({ environmentId: codeExecutionContext.codeWorkspace.environmentId, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, }, - getConvoOwnership, + getConvoLaneContext, reserveConvoLaneGitSeq, setConvoLaneGit, }), diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 4dbbccf3372..975149fda3e 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -25,7 +25,7 @@ const mockCreateAttachedWorkspaceBashTool = jest.fn(() => ({ name: AgentConstant const mockLaneGitRecorder = jest.fn(); const mockCreateLaneGitRecorder = jest.fn(() => mockLaneGitRecorder); const mockSetConvoLaneGit = jest.fn(); -const mockGetConvoOwnership = jest.fn(); +const mockGetConvoLaneContext = jest.fn(); const mockReserveConvoLaneGitSeq = jest.fn(); const attachedWorkspaceOperations = [ 'read_file', @@ -178,7 +178,7 @@ jest.mock('~/models', () => ({ findPluginAuthsByKeys: jest.fn(), getRoleByName: (...args) => mockGetRoleByName(...args), setConvoLaneGit: (...args) => mockSetConvoLaneGit(...args), - getConvoOwnership: (...args) => mockGetConvoOwnership(...args), + getConvoLaneContext: (...args) => mockGetConvoLaneContext(...args), reserveConvoLaneGitSeq: (...args) => mockReserveConvoLaneGitSeq(...args), })); jest.mock('~/config', () => ({ @@ -3296,7 +3296,7 @@ describe('ToolService - Action Capability Gating', () => { user: req.user.id, conversationId: 'resolved-convo', workspace: { environmentId: 'personal-machine', workspaceId: 'project-a' }, - getConvoOwnership: expect.any(Function), + getConvoLaneContext: expect.any(Function), reserveConvoLaneGitSeq: expect.any(Function), setConvoLaneGit: expect.any(Function), }), diff --git a/librechat.example.yaml b/librechat.example.yaml index f99408f12f4..5ccba58eeec 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -938,8 +938,8 @@ endpoints: # allowedRepositories: ["LibreChat-AI/LibreChat"] # # Seconds a looked-up pull request is reused before GitHub is asked again (5 to 3600). # cacheTtlSeconds: 30 - # # Results the shared cache holds before it evicts the oldest (10 to 100000). Raise it on a busy - # # multi-tenant server, or fresh results are evicted and GitHub is asked again. + # # Results cached per credential before the oldest is evicted (10 to 100000). Raise it when many + # # repositories and branches are active at once, or fresh results are evicted and GitHub is asked again. # cacheMaxEntries: 500 # # Bounds on one lookup. A repository with many check runs reads one page of 100 per request. # requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60) diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index 0788f0e5676..a86479f5673 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -1,13 +1,22 @@ import { logger } from '@librechat/data-schemas'; import type { CodeBridgeFetch } from './bridge'; +import { createLaneGitRecorder as createRecorder } from './lane'; import { createAttachedWorkspaceBashTool } from './command'; -import { createLaneGitRecorder } from './lane'; jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), logger: { warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn() }, })); +type RecorderOptions = Parameters[0]; + +/** Places the recorder once, as the tool does, then reports through it in call order. */ +const createLaneGitRecorder = (options: RecorderOptions) => { + const ready = createRecorder(options); + return (laneGit: Parameters>>[0]) => + ready.then((record) => (record ? record(laneGit) : false)); +}; + const head = 'a'.repeat(40); const laneGit = { branch: 'feat/pr-chip', head }; @@ -48,8 +57,13 @@ describe('createLaneGitRecorder', () => { ['an empty user', { user: '', conversationId: 'c1' }], ['a missing conversation', { user: 'u1', conversationId: undefined }], ['an empty conversation', { user: 'u1', conversationId: '' }], - ])('does not record with %s', (_label, ids) => { - const { record, setConvoLaneGit } = make(ids); + ])('does not record with %s', async (_label, ids) => { + const setConvoLaneGit = jest.fn(); + const record = await createRecorder({ + ...ids, + reserveConvoLaneGitSeq: counter(), + setConvoLaneGit, + } as RecorderOptions); expect(record).toBeUndefined(); expect(setConvoLaneGit).not.toHaveBeenCalled(); }); @@ -359,7 +373,7 @@ describe('createLaneGitRecorder ordering', () => { const reserve = jest.fn( () => new Promise((resolve) => releases.push(() => resolve(releases.length))), ); - const getConvoOwnership = jest + const getConvoLaneContext = jest .fn() .mockResolvedValue({ subagentThread: { rootConversationId: 'shared-root' } }); const make = (conversationId: string) => @@ -367,7 +381,7 @@ describe('createLaneGitRecorder ordering', () => { user: 'u1', conversationId, workspace: { environmentId: 'code-mac', workspaceId: 'primary' }, - getConvoOwnership, + getConvoLaneContext, reserveConvoLaneGitSeq: reserve, setConvoLaneGit: jest.fn().mockResolvedValue(true), }); @@ -386,7 +400,7 @@ describe('createLaneGitRecorder ordering', () => { const reserve = jest.fn( () => new Promise((resolve) => releases.push(() => resolve(1))), ); - const getConvoOwnership = jest.fn(async (_user: string, id: string) => ({ + const getConvoLaneContext = jest.fn(async (_user: string, id: string) => ({ subagentThread: { rootConversationId: id === 'child-a' ? 'root-a' : 'root-b' }, })); const make = (conversationId: string) => @@ -394,7 +408,7 @@ describe('createLaneGitRecorder ordering', () => { user: 'u1', conversationId, workspace: { environmentId: 'code-mac', workspaceId: 'primary' }, - getConvoOwnership, + getConvoLaneContext, reserveConvoLaneGitSeq: reserve, setConvoLaneGit: jest.fn().mockResolvedValue(true), }); @@ -425,7 +439,7 @@ describe('createLaneGitRecorder target conversation', () => { it('passes the workspace it ran in, so a stale writer can be fenced', async () => { const { record, setConvoLaneGit } = build(); - await record?.(laneGit); + await record(laneGit); expect(written(setConvoLaneGit)[0]).toMatchObject({ conversationId: 'target-1', workspace: { ...workspace }, @@ -434,15 +448,16 @@ describe('createLaneGitRecorder target conversation', () => { }); it('records a subagent thread on the visible conversation, and requires its workspace to match', async () => { - const getConvoOwnership = jest.fn().mockResolvedValue({ + const getConvoLaneContext = jest.fn().mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root', parentConversationId: 'mid' }, + codeAttachmentEpoch: 0, }); const { record, setConvoLaneGit, reserveConvoLaneGitSeq } = build({ conversationId: 'child-thread', - getConvoOwnership, + getConvoLaneContext, }); - await record?.(laneGit); - expect(getConvoOwnership).toHaveBeenCalledWith('u1', 'child-thread'); + await record(laneGit); + expect(getConvoLaneContext).toHaveBeenCalledWith('u1', 'child-thread'); expect(reserveConvoLaneGitSeq).toHaveBeenCalledWith('u1', 'visible-root'); expect(written(setConvoLaneGit)[0]).toMatchObject({ conversationId: 'visible-root', @@ -451,49 +466,117 @@ describe('createLaneGitRecorder target conversation', () => { }); it('records an ordinary conversation on itself', async () => { - const getConvoOwnership = jest.fn().mockResolvedValue({ user: 'u1' }); - const { record, setConvoLaneGit } = build({ conversationId: 'plain', getConvoOwnership }); - await record?.(laneGit); + const getConvoLaneContext = jest.fn().mockResolvedValue({ codeAttachmentEpoch: 0 }); + const { record, setConvoLaneGit } = build({ conversationId: 'plain', getConvoLaneContext }); + await record(laneGit); expect(written(setConvoLaneGit)[0].conversationId).toBe('plain'); }); it('looks the conversation up once however many reports follow', async () => { - const getConvoOwnership = jest.fn().mockResolvedValue({ + const getConvoLaneContext = jest.fn().mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' }, + codeAttachmentEpoch: 0, }); - const { record, setConvoLaneGit } = build({ conversationId: 'child-2', getConvoOwnership }); - await record?.({ branch: 'a', head }); - await record?.({ branch: 'b', head }); - expect(getConvoOwnership).toHaveBeenCalledTimes(1); + const { record, setConvoLaneGit } = build({ conversationId: 'child-2', getConvoLaneContext }); + await record({ branch: 'a', head }); + await record({ branch: 'b', head }); + expect(getConvoLaneContext).toHaveBeenCalledTimes(1); expect(written(setConvoLaneGit).map((call) => call.conversationId)).toEqual([ 'visible-root', 'visible-root', ]); }); - it('skips a report it cannot place rather than guess, and tries again on the next', async () => { - const getConvoOwnership = jest + it('records nothing when it cannot place the conversation, rather than guess', async () => { + const getConvoLaneContext = jest .fn() - .mockRejectedValueOnce(new Error('mongodb://user:secret@host')) - .mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' } }); - const { record, setConvoLaneGit } = build({ conversationId: 'child-3', getConvoOwnership }); - await expect(record?.({ branch: 'a', head })).resolves.toBe(false); + .mockRejectedValue(new Error('mongodb://user:secret@host')); + const setConvoLaneGit = jest.fn(); + const record = await createRecorder({ + user: 'u1', + conversationId: 'child-3', + workspace, + getConvoLaneContext, + reserveConvoLaneGitSeq: counter(), + setConvoLaneGit, + }); + expect(record).toBeUndefined(); expect(setConvoLaneGit).not.toHaveBeenCalled(); expect(JSON.stringify((logger.warn as jest.Mock).mock.calls)).not.toContain('secret'); - await expect(record?.({ branch: 'b', head })).resolves.toBe(true); - expect(written(setConvoLaneGit)[0].conversationId).toBe('visible-root'); }); it('does not write a subagent lane to the parent without a workspace to verify it against', async () => { - const getConvoOwnership = jest.fn().mockResolvedValue({ + const getConvoLaneContext = jest.fn().mockResolvedValue({ subagentThread: { rootConversationId: 'visible-root' }, + codeAttachmentEpoch: 0, }); - const { record, setConvoLaneGit } = build({ + const setConvoLaneGit = jest.fn(); + const record = await createRecorder({ + user: 'u1', conversationId: 'child-4', - workspace: undefined, - getConvoOwnership, + getConvoLaneContext, + reserveConvoLaneGitSeq: counter(), + setConvoLaneGit, }); - await expect(record?.(laneGit)).resolves.toBe(false); + expect(record).toBeUndefined(); expect(setConvoLaneGit).not.toHaveBeenCalled(); }); + + it('places a conversation that is not saved yet on itself at epoch 0', async () => { + const getConvoLaneContext = jest.fn().mockResolvedValue(null); + const { record, setConvoLaneGit } = build({ getConvoLaneContext }); + await record(laneGit); + expect(written(setConvoLaneGit)[0]).toMatchObject({ + conversationId: 'target-1', + workspace: { ...workspace, epoch: 0 }, + }); + }); +}); + +describe('createLaneGitRecorder placement timing', () => { + const flush = () => new Promise((resolve) => setImmediate(resolve)); + const workspace = { environmentId: 'code-mac', workspaceId: 'primary' }; + + it('settles placement before any report, so a slow lookup cannot reorder reports', async () => { + let release: () => void = () => undefined; + const getConvoLaneContext = jest.fn( + () => + new Promise<{ codeAttachmentEpoch: number }>((resolve) => { + release = () => resolve({ codeAttachmentEpoch: 0 }); + }), + ); + const reserve = counter(); + const pending = createRecorder({ + user: 'u1', + conversationId: 'slow-lookup', + workspace, + getConvoLaneContext, + reserveConvoLaneGitSeq: reserve, + setConvoLaneGit: jest.fn().mockResolvedValue(true), + }); + await flush(); + expect(reserve).not.toHaveBeenCalled(); + release(); + const record = await pending; + expect(record).toBeDefined(); + await record?.({ branch: 'a', head }); + expect(reserve).toHaveBeenCalledTimes(1); + }); + + it('carries the epoch read at creation into every write, even if the chat moves later', async () => { + const getConvoLaneContext = jest.fn().mockResolvedValue({ codeAttachmentEpoch: 3 }); + const setConvoLaneGit = jest.fn().mockResolvedValue(true); + const record = await createRecorder({ + user: 'u1', + conversationId: 'epoch-1', + workspace, + getConvoLaneContext, + reserveConvoLaneGitSeq: counter(), + setConvoLaneGit, + }); + await record?.({ branch: 'a', head }); + await record?.({ branch: 'b', head }); + expect(setConvoLaneGit.mock.calls.map(([call]) => call.workspace.epoch)).toEqual([3, 3]); + expect(getConvoLaneContext).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index 81ec8bb4b69..3c26e8c7db5 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -2,8 +2,16 @@ import { logger } from '@librechat/data-schemas'; import type { WorkspaceLaneGit } from './workspace'; import { getSafeErrorMetadata } from '~/utils'; -/** The workspace a command ran in. `required` demands the conversation have it recorded. */ -export type LaneWorkspace = { environmentId: string; workspaceId: string; required?: boolean }; +/** + * The workspace a command ran in. `required` demands the conversation have it recorded, and + * `epoch` is the attachment epoch read when the tool was created. + */ +export type LaneWorkspace = { + environmentId: string; + workspaceId: string; + required?: boolean; + epoch?: number; +}; export type LaneGitWriter = (input: { user: string; @@ -19,11 +27,17 @@ export type LaneGitWriter = (input: { /** Draws the next report sequence number from the database, so every replica shares one order. */ export type LaneSeqReserver = (user: string, conversationId: string) => Promise; -/** Reads what the recorder needs to place a conversation: whether it is a subagent thread. */ -export type LaneOwnershipReader = ( +/** + * Reads what the recorder needs to place and fence its reports: the subagent route of the + * conversation and its workspace attachment epoch. Null when the conversation is not saved yet. + */ +export type LaneContextReader = ( user: string, conversationId: string, -) => Promise<{ subagentThread?: { rootConversationId?: string | null } | null } | null | undefined>; +) => Promise<{ + subagentThread?: { rootConversationId?: string | null } | null; + codeAttachmentEpoch?: number; +} | null>; const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const MAX_REPO_LENGTH = 256; @@ -34,7 +48,7 @@ const isSafeRepo = (repo: string | undefined): repo is string => REPO_PATTERN.test(repo) && repo.split('/').every((segment) => segment !== '.' && segment !== '..'); -type Placement = { conversationId: string; required: boolean }; +type Placement = { conversationId: string; required: boolean; epoch: number }; type Reservation = Placement & { seq: number }; /** @@ -49,23 +63,22 @@ type Reservation = Placement & { seq: number }; const reservationTails = new Map>(); /** - * Records the lane state a finished command reported, for the owner's conversation. It never - * throws and is never awaited by the command: the branch is a header affordance, so a database - * outage must not fail or delay the agent's tool call. Each report takes a sequence number from - * the database when it arrives and is written with it, so the newest report wins whatever order - * the writes land in. Every report is written, repeats included, because another recorder (a - * sibling subagent thread, another replica) may have changed the lane since this one last wrote, - * and the database already ignores a write that changes nothing the reader can see. Resolves to - * whether the write applied, which is false when a newer report is already stored and for a - * conversation not saved yet. A repo that is not a plain `owner/name` is dropped rather than - * stored. + * Builds the recorder for one tool, placing it once, here, so no report waits on a lookup that + * could let a later report take an earlier turn. The conversation's route and attachment epoch + * are read when the tool is created: a subagent thread is a hidden child of the conversation the + * user sees, its commands run on the parent's machine, so its lane belongs to the visible root + * and that write must match the root's recorded workspace; the epoch fences out a report that + * outlives a move of the workspace, even one that moved away and back. A conversation not saved + * yet is placed on itself at epoch 0. When the context cannot be read nothing is recorded: the + * branch is a header affordance, so a database outage never fails or delays the tool call. + * Resolves to undefined when there is nothing to record against. */ -export function createLaneGitRecorder({ +export async function createLaneGitRecorder({ user, conversationId, repo, workspace, - getConvoOwnership, + getConvoLaneContext, reserveConvoLaneGitSeq, setConvoLaneGit, }: { @@ -73,43 +86,32 @@ export function createLaneGitRecorder({ conversationId: string | undefined; repo?: string; workspace?: { environmentId: string; workspaceId: string }; - getConvoOwnership?: LaneOwnershipReader; + getConvoLaneContext?: LaneContextReader; reserveConvoLaneGitSeq: LaneSeqReserver; setConvoLaneGit: LaneGitWriter; -}): ((laneGit: WorkspaceLaneGit) => Promise) | undefined { +}): Promise<((laneGit: WorkspaceLaneGit) => Promise) | undefined> { if (!user || !conversationId) return undefined; const safeRepo = isSafeRepo(repo) ? repo : undefined; - /** - * A subagent thread is a hidden child of the conversation the user sees, and its commands run on - * the parent's machine, so its lane belongs to the visible root. That write must also match the - * root's recorded workspace, since nothing else ties the child to it. The lookup is made once; - * a failed one is retried on the next report. - */ - let target: Promise | undefined; - const resolveTarget = (): Promise => { - if (target == null) { - const resolving = (async (): Promise => { - if (getConvoOwnership == null) return { conversationId, required: false }; - const owned = await getConvoOwnership(user, conversationId); - const root = owned?.subagentThread?.rootConversationId; - return root - ? { conversationId: root, required: true } - : { conversationId, required: false }; - })(); - target = resolving; - resolving.catch(() => { - if (target === resolving) target = undefined; - }); - } - return target; - }; + let placed: Placement; + try { + const context = getConvoLaneContext ? await getConvoLaneContext(user, conversationId) : null; + const root = context?.subagentThread?.rootConversationId; + placed = { + conversationId: root || conversationId, + required: Boolean(root), + epoch: context?.codeAttachmentEpoch ?? 0, + }; + } catch (error) { + logger.warn('[LaneGit] Failed to place a lane recorder', getSafeErrorMetadata(error)); + return undefined; + } + if (placed.required && workspace == null) return undefined; + const queueKey = `${user}\0${placed.conversationId}`; /** Never rejects, so a failed reservation cannot stall the ones queued behind it. */ const reserve = async (): Promise => { try { - const placed = await resolveTarget(); - if (placed.required && workspace == null) return null; const seq = await reserveConvoLaneGitSeq(user, placed.conversationId); return seq == null ? null : { ...placed, seq }; } catch (error) { @@ -127,7 +129,13 @@ export function createLaneGitRecorder({ ...(safeRepo ? { repo: safeRepo } : {}), seq: reserved.seq, ...(workspace - ? { workspace: { ...workspace, ...(reserved.required ? { required: true } : {}) } } + ? { + workspace: { + ...workspace, + ...(reserved.required ? { required: true } : {}), + epoch: reserved.epoch, + }, + } : {}), }); } catch (error) { @@ -136,30 +144,22 @@ export function createLaneGitRecorder({ } }; + /** + * Fire-and-forget for the command: it never throws and is never awaited. Each report takes its + * sequence number from the database in report order and is written with it, so the newest report + * wins whatever order the writes land in. Every report is written, repeats included, because + * another recorder (a sibling thread, another replica) may have changed the lane since this one + * last wrote, and the database already ignores a write that changes nothing the reader can see. + * Resolves to whether the write applied, which is false when a newer report is already stored + * and for a conversation not saved yet. A repo that is not a plain `owner/name` is dropped. + */ return (laneGit) => { - /** - * The chain is chosen after the target is resolved, so threads that share a visible - * conversation share it. Resolution is memoised and idempotent, so waiting on it here costs - * one lookup per recorder. - */ - const run = async (): Promise => { - let placed: Placement; - try { - placed = await resolveTarget(); - } catch (error) { - logger.warn('[LaneGit] Failed to place a lane report', getSafeErrorMetadata(error)); - return false; - } - const queueKey = `${user}\0${placed.conversationId}`; - const previous = reservationTails.get(queueKey) ?? Promise.resolve(); - const reservation = previous.then(() => reserve()); - reservationTails.set(queueKey, reservation); - void reservation.then(() => { - if (reservationTails.get(queueKey) === reservation) reservationTails.delete(queueKey); - }); - const reserved = await reservation; - return reserved == null ? false : write(laneGit, reserved); - }; - return run(); + const previous = reservationTails.get(queueKey) ?? Promise.resolve(); + const reservation = previous.then(() => reserve()); + reservationTails.set(queueKey, reservation); + void reservation.then(() => { + if (reservationTails.get(queueKey) === reservation) reservationTails.delete(queueKey); + }); + return reservation.then((reserved) => (reserved == null ? false : write(laneGit, reserved))); }; } diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index 5d346e64c27..1e19276b565 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -326,3 +326,29 @@ describe('cache capacity from the caller', () => { expect(find).toHaveBeenCalledTimes(1); }); }); + +describe('cache capacity per credential', () => { + it("does not let one credential's small capacity evict another credential's entries", async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, token: 'big', branch: 'keep', cacheMaxEntries: 100 }); + for (const branch of ['a', 'b', 'c', 'd']) { + await lookup({ ...input, token: 'small', branch, cacheMaxEntries: 2 }); + } + const before = find.mock.calls.length; + await lookup({ ...input, token: 'big', branch: 'keep', cacheMaxEntries: 100 }); + expect(find).toHaveBeenCalledTimes(before); + }); + + it('bounds the credentials it keeps, dropping the one idle longest', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + await lookup({ ...input, token: 'first' }); + for (let index = 0; index < 256; index += 1) { + await lookup({ ...input, token: `other-${index}` }); + } + const before = find.mock.calls.length; + await lookup({ ...input, token: 'first' }); + expect(find).toHaveBeenCalledTimes(before + 1); + }); +}); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index 94697e75a40..c1f27f96584 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -11,6 +11,8 @@ const MIN_COOLDOWN_MS = 10_000; /** GitHub's primary limit resets within the hour; a larger hint is malformed and is capped. */ const MAX_COOLDOWN_MS = 60 * 60_000; const DEFAULT_MAX_ENTRIES = 500; +/** Credentials with a cache partition at once. Past it the least recently used partition goes. */ +const MAX_PARTITIONS = 256; type Entry = { result: PullRequestLookupResult; expiresAt: number }; @@ -20,7 +22,8 @@ const scopeOf = (token: string): string => /** * Caches lookups per credential, repository, branch, recorded commit and lookup policy, and shares - * one in-flight request between concurrent callers with all of those in common. What a token can see is part of the answer (a + * one in-flight request between concurrent callers with all of those in common. Each credential + * has its own bounded partition, sized by the capacity its callers ask for. What a token can see is part of the answer (a * private repository is a pull request for one tenant and nothing for another), so the credential * scopes both the cache and the in-flight map. A rate limit applies to the credential, not the * branch, so it starts a cooldown for every branch under that credential. Callers must have @@ -35,18 +38,39 @@ export function createPullRequestLookup({ now?: () => number; maxEntries?: number; }): PullRequestLookup { - const entries = new Map(); + /** One bounded partition per credential, so a deployment's capacity setting only ever evicts + * entries fetched with its own credential. */ + const partitions = new Map>(); const inflight = new Map>(); const cooldowns = new Map(); const rateLimited: PullRequestLookupResult = { ok: false, error: { code: 'RATE_LIMITED' } }; + function partitionOf(scope: string): Map { + let partition = partitions.get(scope); + if (partition == null) { + partition = new Map(); + partitions.set(scope, partition); + } + /** Recency of use orders the partitions, so the one idle longest is the one dropped. */ + partitions.delete(scope); + partitions.set(scope, partition); + while (partitions.size > MAX_PARTITIONS) { + const idle = partitions.keys().next(); + if (idle.done) break; + partitions.delete(idle.value); + } + return partition; + } + function remember( + scope: string, key: string, result: PullRequestLookupResult, ttlMs: number, capacity: number, ): void { const lifetime = result.ok ? ttlMs : Math.min(ttlMs, FAILURE_TTL_MS); + const entries = partitionOf(scope); entries.delete(key); entries.set(key, { result, expiresAt: now() + lifetime }); while (entries.size > capacity) { @@ -76,7 +100,7 @@ export function createPullRequestLookup({ limits?.maxHeadComparisons, ].join(','); const key = `${scope}\0${repo}#${branch}\0${head ?? ''}\0${policy}`; - const cached = entries.get(key); + const cached = partitions.get(scope)?.get(key); if (cached != null && cached.expiresAt > now()) return cached.result; const pending = inflight.get(key); if (pending != null) return pending; @@ -100,7 +124,7 @@ export function createPullRequestLookup({ } result = { ok: false, error: { code } }; } - remember(key, result, ttlMs, cacheMaxEntries ?? maxEntries); + remember(scope, key, result, ttlMs, cacheMaxEntries ?? maxEntries); return result; })().finally(() => inflight.delete(key)); inflight.set(key, run); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a15c7557a8b..afebda92934 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1868,9 +1868,9 @@ export const agentsEndpointSchema = baseEndpointSchema allowedRepositories: z.array(pullRequestRepositorySchema).max(256).optional(), /** Seconds a looked-up pull request is reused before GitHub is asked again. */ cacheTtlSeconds: z.number().int().min(5).max(3600).optional().default(30), - /** Pull requests the shared cache holds before it evicts the oldest. Size it to the - * distinct credential, repository and branch combinations seen within one cache lifetime, - * or fresh results are evicted and GitHub is asked again. */ + /** Pull requests cached per credential before the oldest is evicted. Size it to the + * distinct repository and branch combinations seen within one cache lifetime, or fresh + * results are evicted and GitHub is asked again. */ cacheMaxEntries: z.number().int().min(10).max(100_000).optional().default(500), /** Longest one GitHub request may take. Raise it behind a slow proxy. */ requestTimeoutSeconds: z.number().int().min(1).max(60).optional().default(10), diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index f06d0b6cfe2..325373f3674 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -9913,3 +9913,167 @@ describe('stampForcedRetention', () => { }, ); }); + +describe('laneGit review hardening', () => { + const head = 'a'.repeat(40); + const mac = { environmentId: 'code-mac', workspaceId: 'primary' }; + const team = { environmentId: 'code-team', workspaceId: 'shared' }; + const seedAttached = async ( + workspaces: Array<{ environmentId: string; workspaceId: string }>, + ) => { + const conversationId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId, + user: 'lane-user', + title: 'Hardening', + endpoint: 'agents', + messages: [], + codeEnvironmentMode: 'attached', + codeWorkspaces: workspaces, + }); + return conversationId; + }; + const move = async ( + conversationId: string, + codeWorkspaces: Array<{ environmentId: string; workspaceId: string }>, + ) => { + const stored = await methods.getConvoCodeEnvironmentDecision('lane-user', conversationId); + return methods.replaceConvoCodeEnvironmentDecision({ + user: 'lane-user', + conversationId, + expected: { + codeEnvironmentMode: stored?.codeEnvironmentMode, + codeWorkspaces: stored?.codeWorkspaces, + codeEnvironmentRevision: stored?.codeEnvironmentRevision, + }, + codeEnvironmentMode: 'attached', + codeWorkspaces: codeWorkspaces as never, + }); + }; + + describe('dotted import and save keys', () => { + it('does not let an import set a lane through dotted keys', async () => { + const imported = uuidv4(); + await methods.bulkSaveConvos([ + { + conversationId: imported, + user: 'lane-user', + title: 'Imported', + 'laneGit.branch': 'forged', + 'laneGit.repo': 'o/forged', + 'laneGit.seq': 999, + 'laneGitSeq.x': 1, + 'codeAttachmentEpoch.x': 5, + }, + ]); + await expect(methods.getConvoLaneGit('lane-user', imported)).resolves.toBeNull(); + const raw = await Conversation.collection.findOne({ conversationId: imported }); + expect(raw).not.toHaveProperty('laneGit'); + expect(raw).not.toHaveProperty('codeAttachmentEpoch'); + expect(await methods.reserveConvoLaneGitSeq('lane-user', imported)).toBe(1); + }); + + it('does not let a save set a lane through dotted keys or unset keys', async () => { + const conversationId = uuidv4(); + await Conversation.create({ conversationId, user: 'lane-user', endpoint: 'agents' }); + const seq = (await methods.reserveConvoLaneGitSeq('lane-user', conversationId)) as number; + await methods.setConvoLaneGit({ + user: 'lane-user', + conversationId, + laneGit: { branch: 'kept', head }, + seq, + }); + await saveConvo( + { userId: 'lane-user' }, + { conversationId, 'laneGit.branch': 'forged', 'laneGit.seq': 999 }, + { unsetFields: { 'laneGit.repo': 1, 'laneGit.head': 1 } }, + ); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toEqual({ + branch: 'kept', + head, + }); + expect(await methods.reserveConvoLaneGitSeq('lane-user', conversationId)).toBe(seq + 1); + }); + }); + + describe('attachment epoch', () => { + const write = ( + conversationId: string, + epoch: number | undefined, + workspace: { environmentId: string; workspaceId: string }, + ) => + methods.reserveConvoLaneGitSeq('lane-user', conversationId).then((seq) => + methods.setConvoLaneGit({ + user: 'lane-user', + conversationId, + laneGit: { branch: 'feat/x', head }, + seq: seq as number, + workspace: { ...workspace, ...(epoch == null ? {} : { epoch }) }, + }), + ); + + it('reads as zero until the conversation is moved, then advances on every move', async () => { + const conversationId = await seedAttached([mac]); + expect( + (await methods.getConvoLaneContext('lane-user', conversationId))?.codeAttachmentEpoch ?? 0, + ).toBe(0); + await move(conversationId, [team]); + expect( + (await methods.getConvoLaneContext('lane-user', conversationId))?.codeAttachmentEpoch, + ).toBe(1); + await move(conversationId, [mac]); + expect( + (await methods.getConvoLaneContext('lane-user', conversationId))?.codeAttachmentEpoch, + ).toBe(2); + }); + + it('applies a report from the epoch it was captured in', async () => { + const conversationId = await seedAttached([mac]); + await expect(write(conversationId, 0, mac)).resolves.toBe(true); + }); + + it('refuses a report from before a move away and back to the same workspace', async () => { + const conversationId = await seedAttached([mac]); + await move(conversationId, [team]); + await move(conversationId, [mac]); + await expect(write(conversationId, 0, mac)).resolves.toBe(false); + await expect(methods.getConvoLaneGit('lane-user', conversationId)).resolves.toBeNull(); + await expect(write(conversationId, 2, mac)).resolves.toBe(true); + }); + + it('is not advanced by admitting a generation', async () => { + const conversationId = await seedAttached([mac]); + await methods.readAdmittedConvoCodeEnvironmentDecision('lane-user', conversationId); + await methods.readAdmittedConvoCodeEnvironmentDecision('lane-user', conversationId); + await expect(write(conversationId, 0, mac)).resolves.toBe(true); + }); + + it('keeps the epoch out of generic saves, unset fields and ordinary reads', async () => { + const conversationId = await seedAttached([mac]); + await move(conversationId, [team]); + await saveConvo( + { userId: 'lane-user' }, + { conversationId, codeAttachmentEpoch: 0 }, + { unsetFields: { codeAttachmentEpoch: 1 } }, + ); + expect( + (await methods.getConvoLaneContext('lane-user', conversationId))?.codeAttachmentEpoch, + ).toBe(1); + const read = await Conversation.findOne({ conversationId }).lean(); + expect(read).not.toHaveProperty('codeAttachmentEpoch'); + }); + }); + + describe('lane context', () => { + it('returns the subagent route and the epoch in one read, owner-scoped', async () => { + const conversationId = await seedAttached([mac]); + await move(conversationId, [team]); + await expect(methods.getConvoLaneContext('lane-user', conversationId)).resolves.toMatchObject( + { + codeAttachmentEpoch: 1, + }, + ); + await expect(methods.getConvoLaneContext('intruder', conversationId)).resolves.toBeNull(); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index e0a38d31975..a0d82df63d2 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -63,14 +63,29 @@ const ACTOR_CHECKPOINT_FIELDS = [ 'agentEventActorSuspension', ] as const; -function stripActorCheckpointFields(record: Record): void { +/** Removes each field and every dotted path beneath it, which Mongo reads as a write to the field. */ +function stripFields(record: Record, fields: readonly string[]): void { for (const key of Object.keys(record)) { - if (ACTOR_CHECKPOINT_FIELDS.some((field) => key === field || key.startsWith(`${field}.`))) { + if (fields.some((field) => key === field || key.startsWith(`${field}.`))) { delete record[key]; } } } +function stripActorCheckpointFields(record: Record): void { + stripFields(record, ACTOR_CHECKPOINT_FIELDS); +} + +/** Written only by the lane methods, so no save, unset or import may reach them. */ +const LANE_PRIVATE_FIELDS = ['laneGit', 'laneGitSeq', 'codeAttachmentEpoch'] as const; + +/** What a lane recorder needs to place and fence its writes, read once when its tool is created. */ +export type ConvoLaneContext = { + subagentThread?: IConversation['subagentThread'] | null; + /** Counts the owner's moves and detaches of the conversation's workspace; 0 until the first. */ + codeAttachmentEpoch: number; +}; + const AGENT_EVENT_ACTOR_RECEIPT_RETENTION_MS = 90 * 24 * 60 * 60_000; const MAX_AGENT_EVENT_ACTOR_SUSPENSION_BYTES = 64 * 1_024; /** MeiliSearch's default `pagination.maxTotalHits` ceiling. */ @@ -330,6 +345,7 @@ export interface ConversationMethods { 'conversationId' | 'codeEnvironmentMode' | 'codeWorkspaces' | 'codeEnvironmentRevision' > | null>; reserveConvoLaneGitSeq(user: string, conversationId: string): Promise; + getConvoLaneContext(user: string, conversationId: string): Promise; setConvoLaneGit(input: { user: string; conversationId: string; @@ -338,7 +354,13 @@ export interface ConversationMethods { /** Reserved with `reserveConvoLaneGitSeq` when the command settled. */ seq: number; /** The workspace the command ran in; the write applies only while the chat is still on it. */ - workspace?: { environmentId: string; workspaceId: string; required?: boolean }; + workspace?: { + environmentId: string; + workspaceId: string; + required?: boolean; + /** The attachment epoch read when the tool was created; see `getConvoLaneContext`. */ + epoch?: number; + }; }): Promise; getConvoLaneGit( user: string, @@ -2467,8 +2489,7 @@ export function createConversationMethods( } /* Remembered tool approvals are granted only by a validated resume. */ delete update.toolApprovalAllows; - delete update.laneGit; - delete update.laneGitSeq; + stripFields(update, LANE_PRIVATE_FIELDS); /** Ordinary saves may seed a decision, but only an explicit move may replace it. */ const decisionOnInsert = { ...(convo.codeEnvironmentMode != null && { @@ -2494,8 +2515,7 @@ export function createConversationMethods( delete unsetFields.titleSetByUser; delete unsetFields.titleRevision; delete unsetFields.toolApprovalAllows; - delete unsetFields.laneGit; - delete unsetFields.laneGitSeq; + stripFields(unsetFields, LANE_PRIVATE_FIELDS); delete unsetFields.codeEnvironmentRevision; delete unsetFields.codeEnvironmentMode; delete unsetFields.codeWorkspaces; @@ -3045,6 +3065,29 @@ export function createConversationMethods( return reserved?.laneGitSeq ?? null; } + /** + * The route and attachment epoch a lane recorder needs, in one owner-scoped read. A subagent + * thread's route names the visible conversation its lane belongs to. The epoch advances only + * when the owner moves or detaches the workspace (never when a generation is admitted), so a + * report captured before a move away and back to the same workspace still reads as stale. + * Resolves to null for a conversation that does not exist yet, or is not this owner's. + */ + async function getConvoLaneContext( + user: string, + conversationId: string, + ): Promise { + const Conversation = mongoose.models.Conversation as Model; + const stored = await Conversation.findOne( + { user, conversationId, ...activeExpirationFilter() }, + 'subagentThread +codeAttachmentEpoch', + ).lean>(); + if (stored == null) return null; + return { + subagentThread: stored.subagentThread ?? null, + codeAttachmentEpoch: stored.codeAttachmentEpoch ?? 0, + }; + } + /** * Record the branch and head a conversation's code lane last reported. Owner-scoped and fenced * by the sequence number reserved when the command settled: the write matches only while the @@ -3072,7 +3115,7 @@ export function createConversationMethods( laneGit: Pick, 'branch' | 'head'>; repo?: string; seq: number; - workspace?: { environmentId: string; workspaceId: string; required?: boolean }; + workspace?: { environmentId: string; workspaceId: string; required?: boolean; epoch?: number }; }): Promise { const Conversation = mongoose.models.Conversation as Model; const next = { @@ -3081,10 +3124,19 @@ export function createConversationMethods( ...(repo ? { repo } : {}), seq, }; + const epochFence = + workspace?.epoch == null + ? [] + : [ + { + codeAttachmentEpoch: workspace.epoch === 0 ? { $in: [null, 0] } : workspace.epoch, + }, + ]; const workspaceFence = workspace == null ? [] : [ + ...epochFence, { $or: [ { @@ -3208,12 +3260,12 @@ export function createConversationMethods( ? { $set: { codeEnvironmentMode, codeWorkspaces }, $unset: { laneGit: 1 }, - $inc: { codeEnvironmentRevision: 1 }, + $inc: { codeEnvironmentRevision: 1, codeAttachmentEpoch: 1 }, } : { $set: { codeEnvironmentMode }, $unset: { codeWorkspaces: 1, laneGit: 1 }, - $inc: { codeEnvironmentRevision: 1 }, + $inc: { codeEnvironmentRevision: 1, codeAttachmentEpoch: 1 }, }, { new: true, timestamps: false }, ).lean(); @@ -3304,8 +3356,7 @@ export function createConversationMethods( delete sanitized.codeApprovalMode; delete sanitized.initial_agent_id; delete sanitized.toolApprovalAllows; - delete sanitized.laneGit; - delete sanitized.laneGitSeq; + stripFields(sanitized, LANE_PRIVATE_FIELDS); delete sanitized.codeEnvironmentRevision; stripActorCheckpointFields(sanitized); if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') { @@ -4391,6 +4442,7 @@ export function createConversationMethods( reserveConvoLaneGitSeq, setConvoLaneGit, getConvoLaneGit, + getConvoLaneContext, readAdmittedConvoCodeEnvironmentDecision, replaceConvoCodeEnvironmentDecision, bulkSaveConvos, diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index edf3dc534cb..8aa5f4d6036 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -64,6 +64,10 @@ const convoSchema: Schema = new Schema( /** Counter that issues `laneGit.seq`, shared by every replica. Server-written only, through * `reserveConvoLaneGitSeq`, and excluded from ordinary conversation reads. */ laneGitSeq: { type: Number, default: undefined, select: false }, + /** Counts the owner's moves and detaches of the conversation's workspace. A lane report + * carries the value read when its tool was created and applies only while it still matches, + * so moving away and back cannot revive an older report. Server-written only. */ + codeAttachmentEpoch: { type: Number, default: undefined, select: false }, agent_id: { type: String, }, diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index b7fa4f8f051..fe0d4938c4d 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -304,6 +304,8 @@ export interface IConversation extends Document { laneGit?: IConversationLaneGit; /** Counter behind `laneGit.seq`; server-written only and excluded from reads. */ laneGitSeq?: number; + /** Moves and detaches of the workspace; fences lane reports from an earlier attachment. */ + codeAttachmentEpoch?: number; /** Immutable primary persisted-agent attribution for Insights. */ initial_agent_id?: string | null; subagentThread?: TSubagentThreadLineage; From d35c5cf6635af5f5fa8f9bf4a2689066be0c9af0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:54:37 +0200 Subject: [PATCH 08/10] fix: Fence Subagent Lane Reports by the Root Epoch and Cover Forks and Moved Heads Read the visible root's attachment epoch for a subagent report, gate lane recording on pullRequests.enabled, make the credential partition bound a config field, revalidate the detail head against the recorded commit, and find fork pull requests through the recorded commit. --- api/server/services/ToolService.js | 1 + .../services/__tests__/ToolService.spec.js | 48 ++++++++++ librechat.example.yaml | 2 + packages/api/src/code/lane.spec.ts | 29 +++++- packages/api/src/code/lane.ts | 5 +- packages/api/src/pulls/github.spec.ts | 92 ++++++++++++++++++- packages/api/src/pulls/github.ts | 47 ++++++++-- packages/api/src/pulls/handler.spec.ts | 3 + packages/api/src/pulls/handler.ts | 1 + packages/api/src/pulls/lookup.spec.ts | 23 +++++ packages/api/src/pulls/lookup.ts | 31 +++++-- packages/api/src/pulls/types.ts | 2 + packages/data-provider/src/config.ts | 3 + .../src/methods/conversation.spec.ts | 32 +++++++ .../data-schemas/src/methods/conversation.ts | 14 ++- 15 files changed, 311 insertions(+), 22 deletions(-) diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 27347feb3ea..cd1c63132a6 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2394,6 +2394,7 @@ async function loadToolsForExecution({ workspaceId: codeExecutionContext.codeWorkspace.workspaceId, workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId, onLaneGit: await createLaneGitRecorder({ + enabled: req.config?.endpoints?.agents?.pullRequests?.enabled === true, user: req.user.id, conversationId: conversationId ?? runtimeRequestBody?.conversationId, repo: codeExecutionContext.codeWorkspace.environment?.repo, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 975149fda3e..29d18e3b809 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -3260,6 +3260,7 @@ describe('ToolService - Action Capability Gating', () => { AgentCapabilities.stateful_code_sessions, ]; const req = createMockReq(capabilities); + req.config.endpoints[EModelEndpoint.agents].pullRequests = { enabled: true }; req.body = { conversationId: 'body-convo', codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }], @@ -3293,6 +3294,7 @@ describe('ToolService - Action Capability Gating', () => { expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith( expect.objectContaining({ + enabled: true, user: req.user.id, conversationId: 'resolved-convo', workspace: { environmentId: 'personal-machine', workspaceId: 'project-a' }, @@ -3307,6 +3309,52 @@ describe('ToolService - Action Capability Gating', () => { expect(mockSetConvoLaneGit).toHaveBeenCalledWith(input); }); + it.each([ + ['unset', undefined], + ['disabled', { enabled: false }], + ])('does not enable lane recording when pull requests are %s', async (_label, setting) => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.execute_code, + AgentCapabilities.stateful_code_sessions, + ]; + const req = createMockReq(capabilities); + if (setting) req.config.endpoints[EModelEndpoint.agents].pullRequests = setting; + req.body = { + codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }], + }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockResolveCodeExecutionContext.mockReturnValueOnce({ + baseUrl: 'http://attached-code.test/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + statefulSessions: true, + environmentType: 'attached', + environmentId: 'personal-machine', + bridgeWorkerId: 'worker-abc', + }); + mockCreateLaneGitRecorder.mockClear(); + + await loadToolsForExecution({ + req, + res: {}, + agent: { + id: 'attached-agent', + tools: [Tools.execute_code], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + }, + conversationId: 'resolved-convo', + toolNames: [AgentConstants.BASH_TOOL], + toolRegistry: new Map([[AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }]]), + actionsEnabled: false, + }); + + expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + it('passes negotiated lane and native sandbox capabilities to the attached bash tool', async () => { const capabilities = [ AgentCapabilities.tools, diff --git a/librechat.example.yaml b/librechat.example.yaml index 5ccba58eeec..6c8a3ff5232 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -941,6 +941,8 @@ endpoints: # # Results cached per credential before the oldest is evicted (10 to 100000). Raise it when many # # repositories and branches are active at once, or fresh results are evicted and GitHub is asked again. # cacheMaxEntries: 500 + # # Credentials cached at once (1 to 10000); the one idle longest is dropped past it. + # cacheMaxCredentials: 256 # # Bounds on one lookup. A repository with many check runs reads one page of 100 per request. # requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60) # lookupTimeoutSeconds: 30 # longest the whole lookup may take (1 to 120) diff --git a/packages/api/src/code/lane.spec.ts b/packages/api/src/code/lane.spec.ts index a86479f5673..922003e5731 100644 --- a/packages/api/src/code/lane.spec.ts +++ b/packages/api/src/code/lane.spec.ts @@ -11,8 +11,8 @@ jest.mock('@librechat/data-schemas', () => ({ type RecorderOptions = Parameters[0]; /** Places the recorder once, as the tool does, then reports through it in call order. */ -const createLaneGitRecorder = (options: RecorderOptions) => { - const ready = createRecorder(options); +const createLaneGitRecorder = (options: Omit) => { + const ready = createRecorder({ enabled: true, ...options }); return (laneGit: Parameters>>[0]) => ready.then((record) => (record ? record(laneGit) : false)); }; @@ -60,6 +60,7 @@ describe('createLaneGitRecorder', () => { ])('does not record with %s', async (_label, ids) => { const setConvoLaneGit = jest.fn(); const record = await createRecorder({ + enabled: true, ...ids, reserveConvoLaneGitSeq: counter(), setConvoLaneGit, @@ -493,6 +494,7 @@ describe('createLaneGitRecorder target conversation', () => { .mockRejectedValue(new Error('mongodb://user:secret@host')); const setConvoLaneGit = jest.fn(); const record = await createRecorder({ + enabled: true, user: 'u1', conversationId: 'child-3', workspace, @@ -512,6 +514,7 @@ describe('createLaneGitRecorder target conversation', () => { }); const setConvoLaneGit = jest.fn(); const record = await createRecorder({ + enabled: true, user: 'u1', conversationId: 'child-4', getConvoLaneContext, @@ -547,6 +550,7 @@ describe('createLaneGitRecorder placement timing', () => { ); const reserve = counter(); const pending = createRecorder({ + enabled: true, user: 'u1', conversationId: 'slow-lookup', workspace, @@ -567,6 +571,7 @@ describe('createLaneGitRecorder placement timing', () => { const getConvoLaneContext = jest.fn().mockResolvedValue({ codeAttachmentEpoch: 3 }); const setConvoLaneGit = jest.fn().mockResolvedValue(true); const record = await createRecorder({ + enabled: true, user: 'u1', conversationId: 'epoch-1', workspace, @@ -580,3 +585,23 @@ describe('createLaneGitRecorder placement timing', () => { expect(getConvoLaneContext).toHaveBeenCalledTimes(1); }); }); + +describe('createLaneGitRecorder when pull requests are off', () => { + it('reads and records nothing', async () => { + const getConvoLaneContext = jest.fn(); + const reserveConvoLaneGitSeq = counter(); + const setConvoLaneGit = jest.fn(); + const record = await createRecorder({ + enabled: false, + user: 'u1', + conversationId: 'off-1', + workspace: { environmentId: 'code-mac', workspaceId: 'primary' }, + getConvoLaneContext, + reserveConvoLaneGitSeq, + setConvoLaneGit, + }); + expect(record).toBeUndefined(); + expect(getConvoLaneContext).not.toHaveBeenCalled(); + expect(reserveConvoLaneGitSeq).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/code/lane.ts b/packages/api/src/code/lane.ts index 3c26e8c7db5..61368ecb27b 100644 --- a/packages/api/src/code/lane.ts +++ b/packages/api/src/code/lane.ts @@ -74,6 +74,7 @@ const reservationTails = new Map>(); * Resolves to undefined when there is nothing to record against. */ export async function createLaneGitRecorder({ + enabled, user, conversationId, repo, @@ -82,6 +83,8 @@ export async function createLaneGitRecorder({ reserveConvoLaneGitSeq, setConvoLaneGit, }: { + /** Whether the deployment shows pull requests; when it does not, nothing is read or recorded. */ + enabled: boolean; user: string | undefined; conversationId: string | undefined; repo?: string; @@ -90,7 +93,7 @@ export async function createLaneGitRecorder({ reserveConvoLaneGitSeq: LaneSeqReserver; setConvoLaneGit: LaneGitWriter; }): Promise<((laneGit: WorkspaceLaneGit) => Promise) | undefined> { - if (!user || !conversationId) return undefined; + if (!enabled || !user || !conversationId) return undefined; const safeRepo = isSafeRepo(repo) ? repo : undefined; let placed: Placement; diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index 37582ce0e64..656608c6823 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -22,6 +22,8 @@ function sourceFor(routes: Record Response>) { const fetchFn = jest.fn(async (input: string) => { const url = String(input); const key = Object.keys(routes).find((fragment) => url.includes(fragment)); + /** Unless a test says otherwise, no pull request carries the recorded commit. */ + if (key == null && /\/commits\/[a-f0-9]+\/pulls\?/.test(url)) return json([]); if (key == null) throw new Error(`unexpected ${url}`); return routes[key](); }); @@ -372,7 +374,7 @@ describe('matching the recorded head', () => { it('accepts the pull request whose head is the recorded commit, without a comparison', async () => { const { source, fetchFn } = sourceFor({ '/pulls?state=open': () => json([{ number: 7, state: 'open', head: { sha: recorded } }]), - '/pulls/7': () => json(pull()), + '/pulls/7': () => json(pull({ head: { sha: recorded } })), '/check-runs': () => json({ total_count: 0, check_runs: [] }), }); await expect(findWith(source, recorded)).resolves.toMatchObject({ number: 7 }); @@ -420,7 +422,7 @@ describe('matching the recorded head', () => { '/pulls?state=open': () => json([{ number: 9, state: 'open', head: { sha } }]), '/pulls?state=closed': () => json([{ number: 7, state: 'closed', head: { sha: recorded } }]), '/compare/': compare('diverged'), - '/pulls/7': () => json(pull({ state: 'closed', merged: true })), + '/pulls/7': () => json(pull({ state: 'closed', merged: true, head: { sha: recorded } })), '/check-runs': () => json({ total_count: 0, check_runs: [] }), }); await expect(findWith(source, recorded)).resolves.toMatchObject({ @@ -539,3 +541,89 @@ describe('check runs follow the current head of the pull request', () => { expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/check-runs'))).toBe(false); }); }); + +describe('a head that moves after the candidate was listed', () => { + const recorded = 'c'.repeat(40); + const moved = 'd'.repeat(40); + const base = { + '/pulls?state=open': () => json([{ number: 7, state: 'open', head: { sha: recorded } }]), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }; + const run = (source: ReturnType) => + source.find({ repo: 'o/r', branch: 'feat/x', head: recorded, token: 't' }); + + it('returns nothing when the detail head no longer contains the recorded commit', async () => { + const { source } = sourceFor({ + ...base, + '/compare/': () => json({ status: 'diverged' }), + '/pulls/7': () => json(pull({ head: { sha: moved } })), + }); + await expect(run(source)).resolves.toBeNull(); + }); + + it('keeps the pull request when the moved head still builds on the recorded commit', async () => { + const { source, fetchFn } = sourceFor({ + ...base, + '/compare/': () => json({ status: 'ahead' }), + '/pulls/7': () => json(pull({ head: { sha: moved } })), + }); + await expect(run(source)).resolves.toMatchObject({ number: 7 }); + expect( + fetchFn.mock.calls.some(([url]) => String(url).includes(`/commits/${moved}/check-runs`)), + ).toBe(true); + }); + + it('does not compare again when the detail head is the one that matched', async () => { + const { source, fetchFn } = sourceFor({ + ...base, + '/pulls/7': () => json(pull({ head: { sha: recorded } })), + }); + await expect(run(source)).resolves.toMatchObject({ number: 7 }); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); + }); +}); + +describe('a pull request whose head is in a fork', () => { + const recorded = 'c'.repeat(40); + const forked = (overrides: Record = {}) => ({ + number: 12, + state: 'open', + head: { sha: recorded, ref: 'feat/x' }, + ...overrides, + }); + const routes = (found: unknown[], status = 200) => ({ + '/pulls?state=open': () => json([]), + '/pulls?state=closed': () => json([]), + '/pulls?per_page': () => json(found, { status }), + '/pulls/12': () => json(pull({ number: 12, head: { sha: recorded } })), + }); + const run = (source: ReturnType) => + source.find({ repo: 'o/r', branch: 'feat/x', head: recorded, token: 't' }); + + it('finds it through the recorded commit when the owner-scoped lists are empty', async () => { + const { source, fetchFn } = sourceFor({ + ...routes([forked()]), + '/check-runs': () => json({ total_count: 0, check_runs: [] }), + }); + await expect(run(source)).resolves.toMatchObject({ number: 12 }); + expect( + fetchFn.mock.calls.some(([url]) => String(url).includes(`/commits/${recorded}/pulls`)), + ).toBe(true); + }); + + it('ignores a pull request for the commit that is on another branch', async () => { + const { source } = sourceFor(routes([forked({ head: { sha: recorded, ref: 'other' } })])); + await expect(run(source)).resolves.toBeNull(); + }); + + it('treats a commit GitHub does not know as no pull request', async () => { + const { source } = sourceFor(routes([], 422)); + await expect(run(source)).resolves.toBeNull(); + }); + + it('does not ask without a recorded commit', async () => { + const { source, fetchFn } = sourceFor(routes([forked()])); + await expect(source.find({ repo: 'o/r', branch: 'feat/x', token: 't' })).resolves.toBeNull(); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/commits/'))).toBe(false); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index 6c0708fd9c8..62ae8a0ca03 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -228,7 +228,12 @@ export function createGitHubPullRequestSource({ maxHeadComparisons: number; }; - async function getJson(pathname: string, lookup: Lookup): Promise { + async function getJson( + pathname: string, + lookup: Lookup, + /** Statuses that mean "nothing there" for this request, besides 404. */ + absent: readonly number[] = [], + ): Promise { let response: Response; try { response = await fetchFn(`${apiBase}${pathname}`, { @@ -254,7 +259,7 @@ export function createGitHubPullRequestSource({ throw new PullRequestSourceError('RATE_LIMITED', retryAfterMs(response)); } /** A repository the token cannot see is indistinguishable from one without pull requests. */ - if (response.status === 404) return null; + if (response.status === 404 || absent.includes(response.status)) return null; throw new PullRequestSourceError('UPSTREAM_ERROR'); } @@ -316,17 +321,21 @@ export function createGitHubPullRequestSource({ * only when its head is that commit or builds on it. A commit GitHub no longer knows is no * match, and both the candidates listed and the comparisons made are bounded by config. */ - const matches = async (candidate: ListItem): Promise => { - if (recorded == null || candidate.sha === recorded) return true; - if (comparisons >= lookup.maxHeadComparisons) return false; - comparisons += 1; - const comparison = await getJson(`${base}/compare/${recorded}...${candidate.sha}`, lookup); + const contains = async (headSha: string): Promise => { + if (recorded == null || headSha === recorded) return true; + const comparison = await getJson(`${base}/compare/${recorded}...${headSha}`, lookup); if (comparison == null) return false; if (!isRecord(comparison) || typeof comparison.status !== 'string') { throw new PullRequestSourceError('UPSTREAM_ERROR'); } return CONTAINS_RECORDED_STATUSES.has(comparison.status); }; + const matches = async (candidate: ListItem): Promise => { + if (recorded == null || candidate.sha === recorded) return true; + if (comparisons >= lookup.maxHeadComparisons) return false; + comparisons += 1; + return contains(candidate.sha); + }; const choose = async (candidates: ListItem[]): Promise => { for (const candidate of candidates) { if (await matches(candidate)) return candidate; @@ -336,11 +345,31 @@ export function createGitHubPullRequestSource({ /** Open first on its own, so closed history on a reused branch name cannot hide it. */ const open = await listPulls('open', lookup.maxCandidatePullRequests); if (open === undefined) return null; + /** + * The head filter names the repository's own owner, so a pull request from a fork is not in + * these lists. The recorded commit finds it instead: GitHub lists the pull requests that + * carry a commit whatever fork they come from, and the branch name narrows them. + */ + const forkCandidates = async (): Promise => { + if (recorded == null) return []; + const found = await getJson( + `${base}/commits/${recorded}/pulls?per_page=${lookup.maxCandidatePullRequests}`, + lookup, + [422], + ); + if (found == null) return []; + if (!Array.isArray(found)) throw new PullRequestSourceError('UPSTREAM_ERROR'); + return found + .filter((item) => isRecord(item) && isRecord(item.head) && item.head.ref === branch) + .map(parseListItem) + .sort((a, b) => Number(a.state !== 'open') - Number(b.state !== 'open')); + }; const chosen = (await choose(open)) ?? (await choose( (await listPulls('closed', recorded == null ? 1 : lookup.maxCandidatePullRequests)) ?? [], - )); + )) ?? + (await choose(await forkCandidates())); if (chosen == null) return null; /** @@ -351,6 +380,8 @@ export function createGitHubPullRequestSource({ const detail = await getJson(`${base}/pulls/${chosen.number}`, lookup); if (detail == null) return null; const pull = parsePull(detail); + /** A push between the list and the detail can move the head off the recorded commit. */ + if (pull.headSha !== chosen.sha && !(await contains(pull.headSha))) return null; const checks = await readCheckRuns(`${base}/commits/${pull.headSha}/check-runs`, lookup); return toConversationPullRequest(pull, checks.runs, checks.incomplete); }, diff --git a/packages/api/src/pulls/handler.spec.ts b/packages/api/src/pulls/handler.spec.ts index bb2cb6df743..ec862de7b40 100644 --- a/packages/api/src/pulls/handler.spec.ts +++ b/packages/api/src/pulls/handler.spec.ts @@ -83,6 +83,7 @@ describe('createConversationPullRequestHandler', () => { head: null, ttlMs: 30_000, cacheMaxEntries: 500, + cacheMaxCredentials: 256, limits: { requestTimeoutMs: 10_000, lookupTimeoutMs: 30_000, @@ -105,12 +106,14 @@ describe('createConversationPullRequestHandler', () => { maxCandidatePullRequests: 25, maxHeadComparisons: 6, cacheMaxEntries: 77, + cacheMaxCredentials: 12, }, }); await run(); expect(lookup).toHaveBeenCalledWith( expect.objectContaining({ cacheMaxEntries: 77, + cacheMaxCredentials: 12, limits: { requestTimeoutMs: 3_000, lookupTimeoutMs: 8_000, diff --git a/packages/api/src/pulls/handler.ts b/packages/api/src/pulls/handler.ts index a0c0a82c881..42a72097836 100644 --- a/packages/api/src/pulls/handler.ts +++ b/packages/api/src/pulls/handler.ts @@ -104,6 +104,7 @@ export function createConversationPullRequestHandler(deps: { token, ttlMs: (settings.cacheTtlSeconds ?? 30) * 1000, cacheMaxEntries: settings.cacheMaxEntries ?? 500, + cacheMaxCredentials: settings.cacheMaxCredentials ?? 256, limits: { requestTimeoutMs: (settings.requestTimeoutSeconds ?? 10) * 1000, lookupTimeoutMs: (settings.lookupTimeoutSeconds ?? 30) * 1000, diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index 1e19276b565..9b77026e791 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -351,4 +351,27 @@ describe('cache capacity per credential', () => { await lookup({ ...input, token: 'first' }); expect(find).toHaveBeenCalledTimes(before + 1); }); + + it('keeps more credentials when the caller configures a larger bound', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + const call = (token: string) => lookup({ ...input, token, cacheMaxCredentials: 300 }); + await call('first'); + for (let index = 0; index < 256; index += 1) await call(`other-${index}`); + const before = find.mock.calls.length; + await call('first'); + expect(find).toHaveBeenCalledTimes(before); + }); + + it('drops credentials sooner when the caller configures a smaller bound', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + const call = (token: string) => lookup({ ...input, token, cacheMaxCredentials: 2 }); + await call('a'); + await call('b'); + await call('c'); + const before = find.mock.calls.length; + await call('a'); + expect(find).toHaveBeenCalledTimes(before + 1); + }); }); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index c1f27f96584..c1636431a94 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -11,8 +11,8 @@ const MIN_COOLDOWN_MS = 10_000; /** GitHub's primary limit resets within the hour; a larger hint is malformed and is capped. */ const MAX_COOLDOWN_MS = 60 * 60_000; const DEFAULT_MAX_ENTRIES = 500; -/** Credentials with a cache partition at once. Past it the least recently used partition goes. */ -const MAX_PARTITIONS = 256; +/** Credentials with a cache partition at once unless configured; the least recently used goes. */ +const DEFAULT_MAX_PARTITIONS = 256; type Entry = { result: PullRequestLookupResult; expiresAt: number }; @@ -45,7 +45,7 @@ export function createPullRequestLookup({ const cooldowns = new Map(); const rateLimited: PullRequestLookupResult = { ok: false, error: { code: 'RATE_LIMITED' } }; - function partitionOf(scope: string): Map { + function partitionOf(scope: string, capacity: number): Map { let partition = partitions.get(scope); if (partition == null) { partition = new Map(); @@ -54,7 +54,7 @@ export function createPullRequestLookup({ /** Recency of use orders the partitions, so the one idle longest is the one dropped. */ partitions.delete(scope); partitions.set(scope, partition); - while (partitions.size > MAX_PARTITIONS) { + while (partitions.size > capacity) { const idle = partitions.keys().next(); if (idle.done) break; partitions.delete(idle.value); @@ -68,9 +68,10 @@ export function createPullRequestLookup({ result: PullRequestLookupResult, ttlMs: number, capacity: number, + credentials: number, ): void { const lifetime = result.ok ? ttlMs : Math.min(ttlMs, FAILURE_TTL_MS); - const entries = partitionOf(scope); + const entries = partitionOf(scope, credentials); entries.delete(key); entries.set(key, { result, expiresAt: now() + lifetime }); while (entries.size > capacity) { @@ -88,7 +89,16 @@ export function createPullRequestLookup({ } } - return async ({ repo, branch, head, token, ttlMs, limits, cacheMaxEntries }) => { + return async ({ + repo, + branch, + head, + token, + ttlMs, + limits, + cacheMaxEntries, + cacheMaxCredentials, + }) => { const scope = scopeOf(token); /** Everything that changes the answer or how long it may be reused is part of the key. */ const policy = [ @@ -124,7 +134,14 @@ export function createPullRequestLookup({ } result = { ok: false, error: { code } }; } - remember(scope, key, result, ttlMs, cacheMaxEntries ?? maxEntries); + remember( + scope, + key, + result, + ttlMs, + cacheMaxEntries ?? maxEntries, + cacheMaxCredentials ?? DEFAULT_MAX_PARTITIONS, + ); return result; })().finally(() => inflight.delete(key)); inflight.set(key, run); diff --git a/packages/api/src/pulls/types.ts b/packages/api/src/pulls/types.ts index 45c57dadac1..e177e67bc6b 100644 --- a/packages/api/src/pulls/types.ts +++ b/packages/api/src/pulls/types.ts @@ -57,6 +57,8 @@ export type PullRequestLookupInput = PullRequestFindInput & { ttlMs: number; /** Entries the shared cache holds before it evicts the oldest; the lookup's own default if omitted. */ cacheMaxEntries?: number; + /** Credentials cached at once; the lookup's own default if omitted. */ + cacheMaxCredentials?: number; }; export type PullRequestLookup = (input: PullRequestLookupInput) => Promise; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index afebda92934..5ed32a042be 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1872,6 +1872,9 @@ export const agentsEndpointSchema = baseEndpointSchema * distinct repository and branch combinations seen within one cache lifetime, or fresh * results are evicted and GitHub is asked again. */ cacheMaxEntries: z.number().int().min(10).max(100_000).optional().default(500), + /** Credentials whose results are cached at once; past it the one idle longest is dropped. + * Raise it when many tenants each configure their own token. */ + cacheMaxCredentials: z.number().int().min(1).max(10_000).optional().default(256), /** Longest one GitHub request may take. Raise it behind a slow proxy. */ requestTimeoutSeconds: z.number().int().min(1).max(60).optional().default(10), /** Longest a whole lookup, every request together, may hold the header request. */ diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 325373f3674..6215f558d72 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -10064,6 +10064,38 @@ describe('laneGit review hardening', () => { }); }); + describe('subagent thread epoch', () => { + const seedThread = async (rootId: string) => { + const conversationId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId, + user: 'lane-user', + title: 'Thread', + endpoint: 'agents', + messages: [], + subagentThread: { rootConversationId: rootId, parentConversationId: rootId }, + }); + return conversationId; + }; + + it("reads the epoch of the visible root the report is written to, not the thread's own", async () => { + const rootId = await seedAttached([mac]); + await move(rootId, [team]); + const threadId = await seedThread(rootId); + await expect(methods.getConvoLaneContext('lane-user', threadId)).resolves.toMatchObject({ + codeAttachmentEpoch: 1, + subagentThread: { rootConversationId: rootId }, + }); + }); + + it('reads zero when the root is gone, so the write is refused rather than guessed', async () => { + const threadId = await seedThread(uuidv4()); + await expect(methods.getConvoLaneContext('lane-user', threadId)).resolves.toMatchObject({ + codeAttachmentEpoch: 0, + }); + }); + }); + describe('lane context', () => { it('returns the subagent route and the epoch in one read, owner-scoped', async () => { const conversationId = await seedAttached([mac]); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index a0d82df63d2..1391d71b4e6 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -82,7 +82,8 @@ const LANE_PRIVATE_FIELDS = ['laneGit', 'laneGitSeq', 'codeAttachmentEpoch'] as /** What a lane recorder needs to place and fence its writes, read once when its tool is created. */ export type ConvoLaneContext = { subagentThread?: IConversation['subagentThread'] | null; - /** Counts the owner's moves and detaches of the conversation's workspace; 0 until the first. */ + /** Counts the owner's moves and detaches of the workspace the report is written to (the visible + * root for a subagent thread); 0 until the first. */ codeAttachmentEpoch: number; }; @@ -3082,9 +3083,18 @@ export function createConversationMethods( 'subagentThread +codeAttachmentEpoch', ).lean>(); if (stored == null) return null; + const rootId = stored.subagentThread?.rootConversationId; + /** A thread's report is written to its visible root, so it is fenced by the root's epoch. */ + const fenced = + rootId && rootId !== conversationId + ? await Conversation.findOne( + { user, conversationId: rootId, ...activeExpirationFilter() }, + '+codeAttachmentEpoch', + ).lean>() + : stored; return { subagentThread: stored.subagentThread ?? null, - codeAttachmentEpoch: stored.codeAttachmentEpoch ?? 0, + codeAttachmentEpoch: fenced?.codeAttachmentEpoch ?? 0, }; } From 30f3ce83cd155df13ca85dcb22405c792492ad24 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:06:48 +0200 Subject: [PATCH 09/10] fix: Keep the Credential Cache Bound Deployment Wide and Budget Head Revalidation Apply the largest configured credential bound to the shared cache, count cache hits as use, spend the comparison budget on a moved head, and fix the data-provider config default test. --- librechat.example.yaml | 3 ++- packages/api/src/pulls/github.spec.ts | 18 +++++++++++++ packages/api/src/pulls/github.ts | 11 ++++---- packages/api/src/pulls/lookup.spec.ts | 29 +++++++++++++++++++- packages/api/src/pulls/lookup.ts | 33 +++++++++++++++-------- packages/data-provider/src/config.spec.ts | 4 +++ packages/data-provider/src/config.ts | 1 + 7 files changed, 80 insertions(+), 19 deletions(-) diff --git a/librechat.example.yaml b/librechat.example.yaml index 6c8a3ff5232..28ff6059494 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -941,7 +941,8 @@ endpoints: # # Results cached per credential before the oldest is evicted (10 to 100000). Raise it when many # # repositories and branches are active at once, or fresh results are evicted and GitHub is asked again. # cacheMaxEntries: 500 - # # Credentials cached at once (1 to 10000); the one idle longest is dropped past it. + # # Credentials cached at once (1 to 10000), deployment-wide; the one idle longest is dropped + # # past it. When principals configure different values, the largest applies. # cacheMaxCredentials: 256 # # Bounds on one lookup. A repository with many check runs reads one page of 100 per request. # requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60) diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index 656608c6823..2159e741ba2 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -573,6 +573,24 @@ describe('a head that moves after the candidate was listed', () => { ).toBe(true); }); + it('spends the comparison budget on the moved head too, and stops when it is spent', async () => { + const { source, fetchFn } = sourceFor({ + ...base, + '/compare/': () => json({ status: 'ahead' }), + '/pulls/7': () => json(pull({ head: { sha: moved } })), + }); + await expect( + source.find({ + repo: 'o/r', + branch: 'feat/x', + head: recorded, + token: 't', + limits: { maxHeadComparisons: 0 }, + }), + ).resolves.toBeNull(); + expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/compare/'))).toBe(false); + }); + it('does not compare again when the detail head is the one that matched', async () => { const { source, fetchFn } = sourceFor({ ...base, diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index 62ae8a0ca03..62a105f7d4e 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -321,8 +321,12 @@ export function createGitHubPullRequestSource({ * only when its head is that commit or builds on it. A commit GitHub no longer knows is no * match, and both the candidates listed and the comparisons made are bounded by config. */ + /** Every comparison, the first match and the revalidation of a moved head alike, spends the + * same configured budget. */ const contains = async (headSha: string): Promise => { if (recorded == null || headSha === recorded) return true; + if (comparisons >= lookup.maxHeadComparisons) return false; + comparisons += 1; const comparison = await getJson(`${base}/compare/${recorded}...${headSha}`, lookup); if (comparison == null) return false; if (!isRecord(comparison) || typeof comparison.status !== 'string') { @@ -330,12 +334,7 @@ export function createGitHubPullRequestSource({ } return CONTAINS_RECORDED_STATUSES.has(comparison.status); }; - const matches = async (candidate: ListItem): Promise => { - if (recorded == null || candidate.sha === recorded) return true; - if (comparisons >= lookup.maxHeadComparisons) return false; - comparisons += 1; - return contains(candidate.sha); - }; + const matches = (candidate: ListItem): Promise => contains(candidate.sha); const choose = async (candidates: ListItem[]): Promise => { for (const candidate of candidates) { if (await matches(candidate)) return candidate; diff --git a/packages/api/src/pulls/lookup.spec.ts b/packages/api/src/pulls/lookup.spec.ts index 9b77026e791..319d7c30ff9 100644 --- a/packages/api/src/pulls/lookup.spec.ts +++ b/packages/api/src/pulls/lookup.spec.ts @@ -363,7 +363,7 @@ describe('cache capacity per credential', () => { expect(find).toHaveBeenCalledTimes(before); }); - it('drops credentials sooner when the caller configures a smaller bound', async () => { + it('drops credentials past the configured bound', async () => { const find = jest.fn().mockResolvedValue(value); const lookup = createPullRequestLookup({ source: { find } }); const call = (token: string) => lookup({ ...input, token, cacheMaxCredentials: 2 }); @@ -374,4 +374,31 @@ describe('cache capacity per credential', () => { await call('a'); expect(find).toHaveBeenCalledTimes(before + 1); }); + + it('does not let a caller with a small bound evict the credentials of one with a large bound', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + for (const token of ['a', 'b', 'c']) { + await lookup({ ...input, token, cacheMaxCredentials: 100 }); + } + await lookup({ ...input, token: 'small', cacheMaxCredentials: 1 }); + const before = find.mock.calls.length; + for (const token of ['a', 'b', 'c']) { + await lookup({ ...input, token, cacheMaxCredentials: 100 }); + } + expect(find).toHaveBeenCalledTimes(before); + }); + + it('counts a cache hit as use, so an active credential is not the one dropped', async () => { + const find = jest.fn().mockResolvedValue(value); + const lookup = createPullRequestLookup({ source: { find } }); + const call = (token: string) => lookup({ ...input, token, cacheMaxCredentials: 2 }); + await call('active'); + await call('idle'); + await call('active'); + await call('newcomer'); + const before = find.mock.calls.length; + await call('active'); + expect(find).toHaveBeenCalledTimes(before); + }); }); diff --git a/packages/api/src/pulls/lookup.ts b/packages/api/src/pulls/lookup.ts index c1636431a94..c53fecd324a 100644 --- a/packages/api/src/pulls/lookup.ts +++ b/packages/api/src/pulls/lookup.ts @@ -38,23 +38,34 @@ export function createPullRequestLookup({ now?: () => number; maxEntries?: number; }): PullRequestLookup { - /** One bounded partition per credential, so a deployment's capacity setting only ever evicts - * entries fetched with its own credential. */ + /** One bounded partition per credential, so a capacity setting only ever evicts entries + * fetched with its own credential. */ const partitions = new Map>(); const inflight = new Map>(); const cooldowns = new Map(); const rateLimited: PullRequestLookupResult = { ok: false, error: { code: 'RATE_LIMITED' } }; - function partitionOf(scope: string, capacity: number): Map { - let partition = partitions.get(scope); - if (partition == null) { - partition = new Map(); - partitions.set(scope, partition); - } - /** Recency of use orders the partitions, so the one idle longest is the one dropped. */ + /** + * The credential bound is one deployment-wide number. Callers may carry different configured + * values (per-principal config), and one asking for a small bound must not evict the credentials + * of another, so the largest value asked for so far applies. + */ + let credentialBound = 0; + + /** Recency of use orders the partitions, so the one idle longest is the one dropped. */ + function touch(scope: string): Map | undefined { + const partition = partitions.get(scope); + if (partition == null) return undefined; partitions.delete(scope); partitions.set(scope, partition); - while (partitions.size > capacity) { + return partition; + } + + function partitionOf(scope: string, credentials: number): Map { + credentialBound = Math.max(credentialBound, credentials); + const partition = touch(scope) ?? new Map(); + partitions.set(scope, partition); + while (partitions.size > credentialBound) { const idle = partitions.keys().next(); if (idle.done) break; partitions.delete(idle.value); @@ -110,7 +121,7 @@ export function createPullRequestLookup({ limits?.maxHeadComparisons, ].join(','); const key = `${scope}\0${repo}#${branch}\0${head ?? ''}\0${policy}`; - const cached = partitions.get(scope)?.get(key); + const cached = touch(scope)?.get(key); if (cached != null && cached.expiresAt > now()) return cached.result; const pending = inflight.get(key); if (pending != null) return pending; diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 539d129d1c9..99fb5de8b05 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -1134,6 +1134,7 @@ describe('agent pull request config', () => { maxCandidatePullRequests: 10, maxHeadComparisons: 3, cacheMaxEntries: 500, + cacheMaxCredentials: 256, }); }); @@ -1190,6 +1191,9 @@ describe('agent pull request config', () => { ['cacheMaxEntries', 9], ['cacheMaxEntries', 100_001], ['cacheMaxEntries', 1.5], + ['cacheMaxCredentials', 0], + ['cacheMaxCredentials', 10_001], + ['cacheMaxCredentials', 1.5], ])('rejects %s of %s', (field, value) => { expect(parse({ [field]: value }).success).toBe(false); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5ed32a042be..05d522b8cd5 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1873,6 +1873,7 @@ export const agentsEndpointSchema = baseEndpointSchema * results are evicted and GitHub is asked again. */ cacheMaxEntries: z.number().int().min(10).max(100_000).optional().default(500), /** Credentials whose results are cached at once; past it the one idle longest is dropped. + * Deployment-wide: when principals configure different values, the largest applies. * Raise it when many tenants each configure their own token. */ cacheMaxCredentials: z.number().int().min(1).max(10_000).optional().default(256), /** Longest one GitHub request may take. Raise it behind a slow proxy. */ From 062c52f3641e0b0ed6c4235234c42a282ef21227 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:22:05 +0200 Subject: [PATCH 10/10] fix: Cool Down Headerless Secondary Rate Limits A 403 whose message names a secondary rate limit now starts the credential cooldown for at least a minute instead of being cached as a ten second failure. --- packages/api/src/pulls/github.spec.ts | 32 +++++++++++++++++++++++++++ packages/api/src/pulls/github.ts | 22 ++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/api/src/pulls/github.spec.ts b/packages/api/src/pulls/github.spec.ts index 2159e741ba2..2351923b342 100644 --- a/packages/api/src/pulls/github.spec.ts +++ b/packages/api/src/pulls/github.spec.ts @@ -645,3 +645,35 @@ describe('a pull request whose head is in a fork', () => { expect(fetchFn.mock.calls.some(([url]) => String(url).includes('/commits/'))).toBe(false); }); }); + +describe('a secondary rate limit without limiting headers', () => { + const secondary = () => + new Response( + JSON.stringify({ message: 'You have exceeded a secondary rate limit. Please wait.' }), + { status: 403 }, + ); + + it('is a rate limit that waits at least a minute', async () => { + const { source } = sourceFor({ '/pulls?state=open': secondary }); + const error = await find(source).catch((caught) => caught); + expect(error.code).toBe('RATE_LIMITED'); + expect(error.retryAfterMs).toBeGreaterThanOrEqual(60_000); + }); + + it('leaves an ordinary 403 as an upstream error', async () => { + const { source } = sourceFor({ + '/pulls?state=open': () => + new Response(JSON.stringify({ message: 'Resource not accessible by token' }), { + status: 403, + }), + }); + await expect(find(source)).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + }); + + it('does not put the response text in the error', async () => { + const { source } = sourceFor({ '/pulls?state=open': secondary }); + const error = await find(source).catch((caught) => caught); + expect(JSON.stringify(error)).not.toContain('secondary rate limit'); + expect(String(error.message)).not.toContain('secondary'); + }); +}); diff --git a/packages/api/src/pulls/github.ts b/packages/api/src/pulls/github.ts index 62a105f7d4e..2b273c0b9af 100644 --- a/packages/api/src/pulls/github.ts +++ b/packages/api/src/pulls/github.ts @@ -179,6 +179,11 @@ export function toConversationPullRequest( }; } +/** GitHub asks for at least a minute when a secondary limit names no wait. */ +const SECONDARY_LIMIT_WAIT_MS = 60_000; +const SECONDARY_LIMIT_MESSAGE = /secondary rate limit|abuse detection/i; +const MAX_ERROR_BODY_CHARS = 2_000; + function isRateLimited(response: Response): boolean { return ( response.status === 429 || @@ -188,6 +193,20 @@ function isRateLimited(response: Response): boolean { ); } +/** + * A secondary limit can arrive as a 403 with neither limiting header, told apart only by its + * message. The body is read bounded and only matched, never stored or returned. + */ +async function isSecondaryLimit(response: Response): Promise { + if (response.status !== 403) return false; + try { + const text = (await response.text()).slice(0, MAX_ERROR_BODY_CHARS); + return SECONDARY_LIMIT_MESSAGE.test(text); + } catch { + return false; + } +} + /** How long GitHub asked callers to wait: `retry-after` seconds, else the quota reset time. */ function retryAfterMs(response: Response): number | undefined { const retryAfter = Number(response.headers.get('retry-after')); @@ -258,6 +277,9 @@ export function createGitHubPullRequestSource({ if (isRateLimited(response)) { throw new PullRequestSourceError('RATE_LIMITED', retryAfterMs(response)); } + if (await isSecondaryLimit(response)) { + throw new PullRequestSourceError('RATE_LIMITED', SECONDARY_LIMIT_WAIT_MS); + } /** A repository the token cannot see is indistinguishable from one without pull requests. */ if (response.status === 404 || absent.includes(response.status)) return null; throw new PullRequestSourceError('UPSTREAM_ERROR');