Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"sharp": "^0.34.5",
"slugify": "^1.6.9",
"stripe": "^21.0.1",
"undici": "^8.10.0",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,37 @@ export default defineEventHandler(async (event) => {
base: CONTENT_BRANCH,
})

const mergeResult = await engine.mergeBranch(branchName)
let mergeResult: { merged: boolean, pullRequestUrl?: string | null }
try {
mergeResult = await engine.mergeBranch(branchName)
}
catch (err) {
// GitHub's merge endpoint answers a real conflict with 409 and the
// provider re-throws it (only "already merged" is absorbed) — so the
// concurrent-writer conflict this loop exists for used to arrive as
// an exception, escape the loop, and reach the UI as an unhandled
// 500 (staging, 2026-08-13 14:04Z). Treat it as the retryable
// conflict it is.
const status = err as { status?: number, statusCode?: number }
if (status.status === 409 || status.statusCode === 409) {
await git.deleteBranch(branchName).catch(() => { /* best-effort */ })
continue
}
throw err
}

if (!mergeResult.merged) {
if (!mergeResult.merged && !mergeResult.pullRequestUrl) {
// A concurrent write landed first and this one conflicts. Drop the
// branch so it doesn't accumulate, then retry from fresh state.
await git.deleteBranch(branchName).catch(() => { /* best-effort */ })
continue
}

// Merged — or landed on `contentrain` with a PR fallback toward a
// protected main. Either way the vocabulary IS on `contentrain`
// (which is exactly what the verification below reads), so a PR
// fallback must not loop back into another identical write.

invalidateBrainCache(projectId)

// The merge can succeed and still lose the term, when the other writer
Expand Down
33 changes: 32 additions & 1 deletion server/utils/mcp-cloud-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/
import type { H3Event } from 'h3'
import { getHeader, getProxyRequestHeaders, proxyRequest, readRawBody, setResponseHeader, setResponseStatus } from 'h3'
import { Agent } from 'undici'
import { MEDIA_TOOL_NAMES, WRITE_TOOL_NAMES } from '~~/server/utils/mcp-tool-classes'
import { errorMessage } from '~~/server/utils/content-strings'
import { invalidateBrainCache } from '~~/server/utils/brain-cache'
Expand Down Expand Up @@ -273,7 +274,30 @@ export async function runMcpCloudProxy(
const fetchOptions = { method: event.method, body: rawBody }

if (!shouldInvalidateBrain) {
return await proxyRequest(event, target, { headers: proxyHeaders, fetchOptions })
// Streaming branch: an MCP session's GET is a long-lived SSE stream
// that legitimately idles far past undici's 300s default bodyTimeout —
// nothing pings it (neither the SDK transport nor the loopback), so
// every quiet session died with an unhandled UND_ERR_BODY_TIMEOUT
// (staging, 3× on 2026-08-13). The dispatcher disables the body
// timeout for this hop only; global fetches keep their safety net.
try {
return await proxyRequest(event, target, {
headers: proxyHeaders,
fetchOptions: { ...fetchOptions, dispatcher: streamingDispatcher } as RequestInit,
})
}
catch (err) {
// Once headers are flushed the response is unsalvageable — a broken
// pump (client hung up, upstream closed mid-stream) must end the
// socket quietly. Re-raising would hand a headers-sent response to
// Nitro's prod error handler, which throws ERR_HTTP_HEADERS_SENT
// into unhandledRejection.
if (event.node.res.headersSent) {
event.node.res.end()
return
}
throw err
}
}

// Write call — this one is buffered instead of streamed.
Expand Down Expand Up @@ -324,6 +348,13 @@ export async function runMcpCloudProxy(
return payload
}

/**
* Dispatcher for the streaming proxy hop only: no body timeout (SSE idles
* indefinitely), headers timeout left at undici's default — the loopback
* answers headers immediately or not at all.
*/
const streamingDispatcher = new Agent({ bodyTimeout: 0 })

/** Response headers that describe the upstream framing, not the payload. */
const HOP_BY_HOP_HEADERS = new Set([
'connection',
Expand Down
129 changes: 129 additions & 0 deletions tests/integration/vocabulary-route.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import { withTestServer } from '../helpers/http'

async function loadVocabularyPatchHandler() {
return (await import('../../server/api/workspaces/[workspaceId]/projects/[projectId]/vocabulary.patch')).default
}

const VOCAB_JSON = JSON.stringify({ version: 1, terms: { cta: { en: 'Get started' } } })

function stubCommonGlobals(overrides: { mergeBranch: ReturnType<typeof vi.fn> }, git: Record<string, unknown>) {
vi.stubGlobal('getRouterParam', vi.fn((_: unknown, key: string) => {
if (key === 'workspaceId') return 'workspace-1'
if (key === 'projectId') return 'project-1'
return undefined
}))
vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({
user: { id: 'editor-1', email: 'editor@example.com' },
accessToken: 'token-1',
}))
vi.stubGlobal('resolveAgentPermissions', vi.fn().mockResolvedValue({
workspaceRole: 'owner',
availableTools: ['save_content'],
specificModels: false,
allowedModels: [],
}))
vi.stubGlobal('resolveProjectContext', vi.fn().mockResolvedValue({ git, contentRoot: '' }))
vi.stubGlobal('createContentEngine', vi.fn().mockReturnValue({
ensureContentBranch: vi.fn().mockResolvedValue(undefined),
mergeBranch: overrides.mergeBranch,
}))
vi.stubGlobal('generateBranchName', vi.fn(() => 'cr/content/vocabulary/1234567890-abcd'))
vi.stubGlobal('invalidateBrainCache', vi.fn())
}

describe('vocabulary route — merge-conflict resilience', () => {
it('retries when the GitHub merge throws a 409 conflict instead of surfacing a 500', async () => {
// The provider re-throws GitHub's 409 on a real merge conflict; the
// route used to let it escape the retry loop (unhandled 500 on
// staging, 2026-08-13 14:04Z).
const mergeBranch = vi.fn()
.mockRejectedValueOnce(Object.assign(new Error('Merge conflict'), { status: 409 }))
.mockResolvedValueOnce({ merged: true, sha: 'sha-2', pullRequestUrl: null })
const git = {
readFile: vi.fn().mockResolvedValue(VOCAB_JSON),
applyPlan: vi.fn().mockResolvedValue({ sha: 'commit-1' }),
deleteBranch: vi.fn().mockResolvedValue(undefined),
}
stubCommonGlobals({ mergeBranch }, git)

await withTestServer({
routes: [
{ path: '/api/workspaces/workspace-1/projects/project-1/vocabulary', handler: await loadVocabularyPatchHandler() },
],
}, async ({ request }) => {
const response = await request('/api/workspaces/workspace-1/projects/project-1/vocabulary', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ terms: { cta: { en: 'Get started' } } }),
})

expect(response.status).toBe(200)
const payload = await response.json()
expect(payload.merged).toBe(true)
expect(payload.vocabulary.terms.cta.en).toBe('Get started')
// conflicted attempt cleaned its branch up, then a fresh write retried
expect(git.deleteBranch).toHaveBeenCalledTimes(1)
expect(git.applyPlan).toHaveBeenCalledTimes(2)
expect(mergeBranch).toHaveBeenCalledTimes(2)
})
})

it('treats a PR fallback (protected main) as landed, not as a conflict to retry', async () => {
// finalize returns merged:false + pullRequestUrl when main is
// protected — but the vocabulary already reached `contentrain`.
// Retrying would re-write the same change up to MAX_ATTEMPTS and then
// report a bogus 409.
const mergeBranch = vi.fn().mockResolvedValue({ merged: false, pullRequestUrl: 'https://github.com/x/y/pull/1' })
const git = {
readFile: vi.fn().mockResolvedValue(VOCAB_JSON),
applyPlan: vi.fn().mockResolvedValue({ sha: 'commit-1' }),
deleteBranch: vi.fn().mockResolvedValue(undefined),
}
stubCommonGlobals({ mergeBranch }, git)

await withTestServer({
routes: [
{ path: '/api/workspaces/workspace-1/projects/project-1/vocabulary', handler: await loadVocabularyPatchHandler() },
],
}, async ({ request }) => {
const response = await request('/api/workspaces/workspace-1/projects/project-1/vocabulary', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ terms: { cta: { en: 'Get started' } } }),
})

expect(response.status).toBe(200)
expect(git.applyPlan).toHaveBeenCalledTimes(1)
expect(git.deleteBranch).not.toHaveBeenCalled()
})
})

it('propagates non-conflict merge failures unchanged', async () => {
const mergeBranch = vi.fn().mockRejectedValue(Object.assign(new Error('Bad credentials'), { status: 401 }))
const git = {
readFile: vi.fn().mockResolvedValue(VOCAB_JSON),
applyPlan: vi.fn().mockResolvedValue({ sha: 'commit-1' }),
deleteBranch: vi.fn().mockResolvedValue(undefined),
}
stubCommonGlobals({ mergeBranch }, git)

await withTestServer({
routes: [
{ path: '/api/workspaces/workspace-1/projects/project-1/vocabulary', handler: await loadVocabularyPatchHandler() },
],
}, async ({ request }) => {
const response = await request('/api/workspaces/workspace-1/projects/project-1/vocabulary', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ terms: { cta: { en: 'Get started' } } }),
})

// h3 maps the error's own status through; the point is that it is
// NOT swallowed into the conflict-retry path.
expect(response.status).toBe(401)
expect(git.applyPlan).toHaveBeenCalledTimes(1)
expect(git.deleteBranch).not.toHaveBeenCalled()
})
})
})
Loading