Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
726468f
test: 记录任务队列复现与基线结果
Oct 22, 2026
5500dd8
fix: 加固任务租约并增加失败退避
Oct 22, 2026
3836f39
fix: 补齐启动恢复配置竞态与重试租约保护
Oct 22, 2026
3655938
feat: 增加任务策略注册表与影子调度对比
Oct 22, 2026
e50d602
docs: 确认任务队列权重为4比3比2比2比1
Oct 22, 2026
e426aed
fix: 统一后台任务执行入口与导入等待流程
Oct 22, 2026
a074e8b
fix: 启用加权轮询与可运行任务先进先出调度
Oct 22, 2026
597de81
fix: 补齐队列公平调度与预算暂停边界
Oct 22, 2026
6646adc
fix: 增加任务分层并发限制与连续补位
Oct 22, 2026
23efb8b
fix: 补齐持续调度唤醒与共享任务并发保护
Oct 22, 2026
2bd8668
fix: 修复写入开关与持续补位响应及模型限流
Oct 22, 2026
53d312b
Merge remote-tracking branch 'upstream/v1.2.6' into test-memory-queue…
Oct 22, 2026
ca5ca47
fix: 保留迁移后的独立记忆模型连接
Oct 22, 2026
7372543
fix: 隔离历史扫描的摘要向量与标题任务
Oct 23, 2026
a74b5ec
fix: 精简历史会话标题的来源查询
Oct 23, 2026
b827f7c
fix: 分片检查后台队列并及时响应新任务
Oct 23, 2026
299a4a1
fix: 保留受阻队列的轮次机会
Oct 23, 2026
abde972
docs: 记录队列整体回归与安装验证结果
Oct 23, 2026
a960abe
Merge remote-tracking branch 'upstream/v1.2.6' into test-memory-queue…
Oct 23, 2026
7b295aa
docs: 记录同步后交付复核与长任务等待边界
Oct 23, 2026
bf1939a
Merge remote-tracking branch 'upstream/v1.2.6' into test-memory-queue…
Oct 23, 2026
d407639
fix: 避免含密钥提示的限流错误阻塞自动重试
Oct 23, 2026
ad08eb4
fix: 分离向量重试租约计数并增加随机退避
Oct 23, 2026
4bc4106
fix: 补齐实时任务优先调度与共享许可及运行观测
Oct 23, 2026
02a300e
fix: 退出时关闭后端通知流并保留在途写入
Oct 23, 2026
dedd07a
docs: 记录队列复查与最终安装包测试结果
Oct 23, 2026
f0c8070
fix: 为实时任务预留一个全局槽位
Oct 23, 2026
7b83452
docs: 记录实时任务预留槽位打包测试结果
Oct 23, 2026
fdc58fd
chore: 合并最新v1.2.6并保留队列与模型兼容修复
Oct 24, 2026
77a077e
docs: 记录提交前同步回归与新包验收
Oct 24, 2026
7607d35
fix: 修复队列资源唤醒与分类维护开销并完善回归验证
Oct 25, 2026
bf26561
fix: 保留队列扫描进度并修复依赖唤醒与到期遗漏
Oct 27, 2026
c11b4d2
Merge remote-tracking branch 'upstream/v1.2.6' into test-memory-queue…
Oct 27, 2026
9673ccf
test: 适配导入状态回归的租约过期与失败退避
Oct 27, 2026
adb01fa
fix: 减少队列到期查询并修复跨期限任务遗漏
Oct 28, 2026
b6ab2df
chore: 合入 v1.2.6 并兼容向量维护与队列租约
Oct 28, 2026
1cd72bd
test: 补齐重试租约夹具的执行者身份
Oct 28, 2026
3a49052
chore: 合入 v1.2.6 并兼容异步会话任务调度
Oct 28, 2026
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
3 changes: 3 additions & 0 deletions App/backend/local-api-contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,7 @@ export const CatalogEndpointInputSchema = z.object({
endpointId: z.string().trim().min(1),
apiBase: z.string().url(),
protocol: ModelEndpointProtocolSchema,
inheritProviderDefaults: z.boolean().optional(),
apiKey: z.string().optional(),
extraHeaders: z.record(z.string(), z.string()).optional(),
extraBody: z.record(z.string(), z.unknown()).optional(),
Expand Down Expand Up @@ -1037,6 +1038,7 @@ export const ModelConfigTestInputSchema = z.object({
provider: ModelConfigTestProviderSchema,
endpointId: z.string().trim().min(1),
protocol: ModelEndpointProtocolSchema,
inheritProviderDefaults: z.boolean().optional(),
apiBase: z.string().url(),
modelId: z.string().min(1),
apiKey: z.string().min(1).optional(),
Expand Down Expand Up @@ -1146,6 +1148,7 @@ export const CatalogEndpointViewSchema = z.object({
endpointId: z.string().min(1),
apiBase: z.string().url(),
protocol: ModelEndpointProtocolSchema,
inheritProviderDefaults: z.boolean().optional(),
hasApiKey: z.boolean(),
apiKeyMasked: z.string(),
apiKey: z.string().default(""),
Expand Down
13 changes: 9 additions & 4 deletions App/backend/local-api-contracts/src/model-catalog-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface RuntimeCatalogEndpoint {
apiBase: string;
protocol: ModelEndpointProtocol;
apiKey?: string;
/** False keeps credentials and request options local to this endpoint. */
inheritProviderDefaults?: boolean;
extraHeaders?: Record<string, string>;
extraBody?: Record<string, unknown>;
region?: string;
Expand Down Expand Up @@ -155,10 +157,12 @@ export function resolveAssignedModel(input: ResolveAssignedModelInput): ModelSel
}
if (!providerOwnerMatches(preset, provider, input.activeAccountId)) return UNAVAILABLE;

const inherited = endpoint.inheritProviderDefaults === false ? {} : provider;
const apiKey = endpoint.apiKey ?? inherited.apiKey;
let extraBody: Readonly<Record<string, unknown>>;
try {
extraBody = deepFreeze(structuredClone({
...(provider.extraBody ?? {}),
...(inherited.extraBody ?? {}),
...(endpoint.extraBody ?? {})
}));
} catch {
Expand All @@ -183,12 +187,12 @@ export function resolveAssignedModel(input: ResolveAssignedModelInput): ModelSel
endpointId: preset.endpoint,
protocol: endpoint.protocol,
apiBase: endpoint.apiBase,
...(endpoint.apiKey ?? provider.apiKey
? { apiKey: endpoint.apiKey ?? provider.apiKey }
...(apiKey
? { apiKey }
: {}),
...(provider.ownerAccountId ? { ownerAccountId: provider.ownerAccountId } : {}),
extraHeaders: Object.freeze({
...(provider.extraHeaders ?? {}),
...(inherited.extraHeaders ?? {}),
...(endpoint.extraHeaders ?? {})
}),
extraBody
Expand Down Expand Up @@ -329,6 +333,7 @@ function isRuntimeEndpoint(value: unknown): value is RuntimeCatalogEndpoint {
&& typeof value.protocol === "string"
&& PROTOCOLS.has(value.protocol as ModelEndpointProtocol)
&& (value.apiKey === undefined || typeof value.apiKey === "string")
&& (value.inheritProviderDefaults === undefined || typeof value.inheritProviderDefaults === "boolean")
&& validStringRecord(value.extraHeaders)
&& validUnknownRecord(value.extraBody)
&& (value.region === undefined || typeof value.region === "string");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { CatalogEndpointInputSchema, CatalogEndpointViewSchema, ModelConfigTestInputSchema } from "../src/index.js";
import { resolveAssignedModel, type RuntimeModelCatalog } from "../src/model-catalog-resolver.js";
import { resolveAssignedModel as resolveMemoryModel } from "../../../../Memory/src/contracts/model-catalog-resolver.js";

function catalog(): RuntimeModelCatalog {
return {
providers: { openai: {
apiKey: "unrelated-cloud-key", extraHeaders: { Authorization: "unrelated-header", "X-Cloud": "cloud" },
extraBody: { model: "unrelated-model", temperature: 0.9 },
endpoints: { memory: { apiBase: "http://127.0.0.1:1234/v1", protocol: "openai-chat-completions" } }
} },
modelPresets: { memory: { provider: "openai", endpoint: "memory", model: "own-model", source: "byok", capabilities: ["memory_summary"] } },
modelAssignments: { byok: { memorySummary: "memory" } }
};
}

describe.each([["shared", resolveAssignedModel], ["standalone Memory", resolveMemoryModel]] as const)("%s endpoint inheritance", (_label, resolve) => {
it.each([undefined, "", "own-key"])("keeps an independent endpoint's own credential (%s) and request options", key => {
const c = catalog(), e = c.providers!.openai!.endpoints!.memory!;
Object.assign(e, { inheritProviderDefaults: false, extraHeaders: { "X-Own": "own" }, extraBody: { seed: 7 } });
if (key !== undefined) e.apiKey = key;
const before = structuredClone(c);
const r = resolve({ catalog: c, mode: "byok", capability: "memory_summary" });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.provider.apiKey).toBe(key || undefined);
expect(r.provider.extraHeaders).toEqual({ "X-Own": "own" });
expect(r.provider.extraBody).toEqual({ seed: 7 });
expect(c).toEqual(before);
c.providers!.openai!.apiKey = "rotated-cloud-key";
c.providers!.openai!.extraHeaders = { Authorization: "rotated-header" };
expect(resolve({ catalog: c, mode: "byok", capability: "memory_summary" })).toEqual(r);
});

it.each([undefined, true])("preserves inherited defaults and later credential rotation (%s)", flag => {
const c = catalog(), e = c.providers!.openai!.endpoints!.memory!;
if (flag !== undefined) Object.assign(e, { inheritProviderDefaults: flag });
e.extraHeaders = { "X-Own": "own" }; e.extraBody = { seed: 7 };
const r = resolve({ catalog: c, mode: "byok", capability: "memory_summary" });
expect(r.ok && r.provider.apiKey).toBe("unrelated-cloud-key");
expect(r.ok && r.provider.extraHeaders).toEqual({ Authorization: "unrelated-header", "X-Cloud": "cloud", "X-Own": "own" });
expect(r.ok && r.provider.extraBody).toEqual({ model: "unrelated-model", temperature: 0.9, seed: 7 });
c.providers!.openai!.apiKey = "rotated-key";
const rotated = resolve({ catalog: c, mode: "byok", capability: "memory_summary" });
expect(rotated.ok && rotated.provider.apiKey).toBe("rotated-key");
});

it.each([null, "false", 0])("rejects malformed inheritance configuration (%s)", flag => {
const c = catalog(); Object.assign(c.providers!.openai!.endpoints!.memory!, { inheritProviderDefaults: flag });
expect(resolve({ catalog: c, mode: "byok", capability: "memory_summary" })).toEqual({ ok: false, code: "model_selection_unavailable" });
});
});

it("retains the optional false flag in catalog and connection-test API round trips", () => {
const endpoint = { endpointId: "memory", apiBase: "http://127.0.0.1:1234/v1", protocol: "openai-chat-completions", inheritProviderDefaults: false };
expect(CatalogEndpointInputSchema.parse(endpoint)).toHaveProperty("inheritProviderDefaults", false);
expect(CatalogEndpointViewSchema.parse({ ...endpoint, hasApiKey: false, apiKeyMasked: "" })).toHaveProperty("inheritProviderDefaults", false);
expect(ModelConfigTestInputSchema.parse({ ...endpoint, provider: "openai_compatible", modelId: "own-model" })).toHaveProperty("inheritProviderDefaults", false);
expect(CatalogEndpointInputSchema.safeParse({ ...endpoint, inheritProviderDefaults: "false" }).success).toBe(false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ async function dispatchComposioTool(
* @param options Route dependencies.
*/
export function registerComposioMcpRoutes(app: FastifyInstance, options: RegisterComposioMcpRoutesOptions): void {
const notificationStreams = new Set<StreamableHTTPServerTransport>();
let closing = false;
app.addHook("preClose", async () => {
closing = true;
// GET holds an idle notification stream. Tool POSTs may still be writing
// external data, so leave those requests to finish normally.
await Promise.all([...notificationStreams].map(transport => transport.close()));
});
const handler = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
const token = singleHeader(request.headers[MCP_TOKEN_HEADER]);
if (!token || token !== options.mcpToken) {
Expand All @@ -147,11 +155,20 @@ export function registerComposioMcpRoutes(app: FastifyInstance, options: Registe

reply.hijack();
reply.raw.on("close", () => {
notificationStreams.delete(transport);
void transport.close();
void server.close();
});

await server.connect(transport);
if (request.method === "GET") {
if (closing) {
await transport.close();
reply.raw.end();
return;
}
notificationStreams.add(transport);
}
await transport.handleRequest(request.raw, reply.raw, request.body);
};

Expand Down
33 changes: 31 additions & 2 deletions App/backend/src/adapters/inbound/local-api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast
const app = Fastify({ logger: false });
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
const authenticateRuntimeToken = createRuntimeTokenPreHandler(options.permissionManager);
const eventStreams = new Set<() => void>();
let closing = false;
const registerEventStream = (stop: () => void) => {
// Authentication may finish after preClose has already drained existing streams.
if (closing) stop();
else eventStreams.add(stop);
return () => { eventStreams.delete(stop); };
};
app.addHook("preClose", async () => {
closing = true;
// The renderer closes after this server; do not wait for its EventSource to
// disconnect. End only our notification streams, preserving ordinary writes.
for (const stop of eventStreams) stop();
});
app.addHook("onResponse", async () => {
// A request already running at preClose can become idle after Fastify's
// initial idle sweep. Preserve its response, then retire the idle socket.
if (closing) app.server.closeIdleConnections();
});

if (options.knowledge) {
registerKnowledgeRoutes(app, { ...options.knowledge, authenticate: authenticateRuntimeToken });
Expand Down Expand Up @@ -151,7 +170,7 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast
return reply.code(401).send({ error: "unauthorized" });
}

startSse(reply, heartbeatIntervalMs, getSingleHeaderValue(request.headers.origin), options.services.progressBus);
startSse(reply, heartbeatIntervalMs, getSingleHeaderValue(request.headers.origin), options.services.progressBus, registerEventStream);
});

return app;
Expand Down Expand Up @@ -216,7 +235,8 @@ function startSse(
reply: FastifyReply,
heartbeatIntervalMs: number,
origin: string | undefined,
progressBus: BackendServices["progressBus"]
progressBus: BackendServices["progressBus"],
registerEventStream: (stop: () => void) => () => void
): void {
// SSE is a long-lived response, so we take over the raw response to prevent Fastify from automatically ending the request.
reply.hijack();
Expand Down Expand Up @@ -284,13 +304,22 @@ function startSse(
});
});

let cleanedUp = false;
let unregister = () => {};
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
// Clean up the heartbeat timer promptly after the client disconnects, avoiding orphaned tasks lingering in the background.
clearInterval(interval);
unsubscribeScanProgress();
unsubscribeScanCompleted();
unregister();
};

reply.raw.once("close", cleanup);
reply.raw.once("error", cleanup);
unregister = registerEventStream(() => {
cleanup();
reply.raw.end();
});
}
33 changes: 32 additions & 1 deletion App/backend/src/infrastructure/memmy-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export interface RuntimeProjectionResult {
memoryConfigAffected: boolean;
}

export interface CatalogEndpointRequestOptions {
apiBase: string;
protocol: string;
inheritProviderDefaults?: boolean;
apiKey?: string;
extraHeaders?: Record<string, string>;
}

export interface MemmyConfigWriter {
readModelConfig?(): Promise<ModelConfigView>;

Expand All @@ -119,6 +127,8 @@ export interface MemmyConfigWriter {

readEndpointApiKey?(provider: string, endpointId: string): Promise<string | null>;

readEndpointRequestOptions?(provider: string, endpointId: string): Promise<CatalogEndpointRequestOptions | null>;

/** Atomically persist the active account/BYOK namespace without rewriting the model catalog. */
writeUserMode?(mode: UserMode): Promise<void>;

Expand Down Expand Up @@ -206,6 +216,10 @@ export function createMemmyConfigWriter(options: CreateMemmyConfigWriterOptions
return readCatalogEndpointApiKey(configPath, provider, endpointId);
},

async readEndpointRequestOptions(provider, endpointId) {
return readCatalogEndpointRequestOptions(configPath, provider, endpointId);
},

async writeUserMode(mode) {
await mutateRuntimeConfig(configPath, (config) => {
const app = asRecord(config.app);
Expand Down Expand Up @@ -276,7 +290,24 @@ export async function readCatalogEndpointApiKey(
const provider = asRecord(asRecord(config.providers)?.[providerId]);
const endpoint = asRecord(asRecord(provider?.endpoints)?.[endpointId]);
if (!provider || !endpoint) return null;
return existingString(endpoint.apiKey) ?? existingString(provider.apiKey) ?? null;
return existingString(endpoint.apiKey) ?? (endpoint.inheritProviderDefaults === false ? null : existingString(provider.apiKey)) ?? null;
}

export async function readCatalogEndpointRequestOptions(
configPath: string, providerId: string, endpointId: string
): Promise<CatalogEndpointRequestOptions | null> {
const config = await readCurrentRuntimeConfig(configPath);
const provider = asRecord(asRecord(config.providers)?.[providerId]);
const endpoint = asRecord(asRecord(provider?.endpoints)?.[endpointId]);
if (!provider || !endpoint || !existingString(endpoint.apiBase) || !existingString(endpoint.protocol)) return null;
const inherited = endpoint.inheritProviderDefaults === false ? {} : provider;
const apiKey = existingString(endpoint.apiKey) ?? existingString(inherited.apiKey);
const extraHeaders = Object.fromEntries(Object.entries({
...asRecord(inherited.extraHeaders), ...asRecord(endpoint.extraHeaders)
}).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
return { apiBase: existingString(endpoint.apiBase)!, protocol: existingString(endpoint.protocol)!,
...(typeof endpoint.inheritProviderDefaults === "boolean" ? { inheritProviderDefaults: endpoint.inheritProviderDefaults } : {}),
...(apiKey ? { apiKey } : {}), ...(Object.keys(extraHeaders).length ? { extraHeaders } : {}) };
}

async function readCurrentRuntimeConfig(configPath: string): Promise<Record<string, unknown>> {
Expand Down
Loading
Loading