feat(server): 借鉴 vibe-kanban 优化子进程管理与消息可靠性

- 添加内存消息缓冲区,解决 tab 切换后用户消息丢失问题
- prompt() 去除重复 waitReady(),减少不必要的等待
- 新增 kill_on_drop 工具(FinalizationRegistry),待后续启用
- 新增权限模式 hooks 自动配置(仅在显式指定 permission_mode 时生效)
- 清除 serve 模式下的调试 timing 日志
This commit is contained in:
DoSun 2026-05-12 20:37:12 +08:00
parent daa2dce7b9
commit ef151d84ba
7 changed files with 405 additions and 106 deletions

View File

@ -53,7 +53,6 @@ export class EventBus {
}
publish(event: string, data: unknown): void {
const tPublish = event === 'session.status' ? Date.now() : 0
const entry: BusEvent = { event, data }
this.buffer.push(entry)
if (this.buffer.length > this.bufferSize) {
@ -91,13 +90,6 @@ export class EventBus {
this.clients.delete(id)
}
}
if (event === 'session.status' && tPublish > 0) {
const dataObj = data as Record<string, unknown>
const sessionId = dataObj?.sessionID ?? dataObj?.session_id
process.stderr.write(
`[serve:timing:${sessionId}] eventBus.publish('session.status') → ${clientCount} SSE clients, +${Date.now() - tPublish}ms in publish loop\n`,
)
}
}
publishSessionEvent(

View File

@ -5,8 +5,16 @@ import {
readSessionMessages,
readSessionTodos,
readSessionDiff,
type SessionMessage,
} from '../transcriptReader.js'
function dedupeMessages(disk: SessionMessage[], memory: SessionMessage[]): SessionMessage[] {
if (disk.length === 0) return memory
const diskUuids = new Set(disk.map(m => m.uuid))
const tail = memory.filter(m => !diskUuids.has(m.uuid))
return [...disk, ...tail]
}
export function createMessageRoutes(sessionManager: SessionManager): Hono {
return new Hono()
.get('/session/:sessionID/message', async c => {
@ -18,7 +26,7 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
const before = url.searchParams.get('before') ?? undefined
const includeSystem = url.searchParams.get('include_system') === 'true'
const { messages, nextCursor } = await readSessionMessages({
const { messages: diskMessages, nextCursor } = await readSessionMessages({
sessionId: id,
cwd: handle?.spawnCwd ?? handle?.cwd,
limit,
@ -26,6 +34,10 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
includeSystem,
})
const messages = handle && handle.messageBuffer.length > 0
? dedupeMessages(diskMessages, [...handle.messageBuffer])
: diskMessages
if (nextCursor) {
c.header('Link', `<${url.pathname}?limit=${limit}&before=${nextCursor}>; rel="prev"`)
c.header('X-Next-Cursor', nextCursor)

View File

@ -12,6 +12,8 @@ import {
} from '../errors.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
import { canonicalizePath } from '../../utils/sessionStoragePortable.js'
import { buildHooksForPermissionMode, mergeHooks } from '../../utils/permissions/permissionModeHooks.js'
import { permissionModeFromString } from '../../utils/permissions/PermissionMode.js'
/** 从磁盘历史记录查找 session 的原始 cwd用于 resume 时恢复正确的工作目录 */
async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
@ -124,6 +126,26 @@ export function createSessionRoutes(
sessionManager: SessionManager,
eventBus: EventBus,
): Hono {
async function getOrResumeSession(id: string): Promise<import('../sessionHandle.js').SessionHandle> {
const handle = sessionManager.getSession(id)
if (handle) return handle
const cwd = await getHistoryCwd(id)
try {
const resumed = await sessionManager.createSession({
cwd,
sessionId: id,
resumeSessionId: id,
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
})
await resumed.waitReady(30000)
return resumed
} catch (err) {
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
}
}
return new Hono()
.post('/session', async c => {
const body = await c.req.json<{
@ -155,6 +177,13 @@ export function createSessionRoutes(
}
try {
let hooks = body.hooks
if (permissionMode) {
const resolvedMode = permissionModeFromString(permissionMode)
const autoHooks = buildHooksForPermissionMode(resolvedMode)
hooks = mergeHooks(autoHooks, body.hooks)
}
const handle = await sessionManager.createSession({
cwd,
sessionId: body.session_id,
@ -163,7 +192,7 @@ export function createSessionRoutes(
systemPrompt: body.system_prompt,
resumeSessionId: body.resume_session_id,
resumeSessionAt: body.resume_session_at,
hooks: body.hooks,
hooks,
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
})
@ -360,22 +389,7 @@ export function createSessionRoutes(
})
.post('/session/:sessionID/prompt', async c => {
const id = c.req.param('sessionID')
let handle = sessionManager.getSession(id)
if (!handle) {
const cwd = await getHistoryCwd(id)
try {
handle = await sessionManager.createSession({
cwd,
sessionId: id,
resumeSessionId: id,
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
})
await handle.waitReady(30000)
} catch (err) {
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
}
}
const handle = await getOrResumeSession(id)
const body = await c.req.json<{
content?: string
@ -450,82 +464,35 @@ export function createSessionRoutes(
sessionID: id,
status: { type: 'busy' },
})
const tBusyEmitted = Date.now()
process.stderr.write(
`[serve:timing:${id}] busy emitted at +${tBusyEmitted - tEntry}ms (immediate)\n`,
)
let handle = sessionManager.getSession(id)
// 历史 session 不在内存中,自动恢复
if (!handle) {
process.stderr.write(
`[serve:timing:${id}] historical session, starting recovery...\n`,
)
const cwd = await getHistoryCwd(id)
try {
handle = await sessionManager.createSession({
cwd,
sessionId: id,
resumeSessionId: id,
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
})
const tCreated = Date.now()
process.stderr.write(
`[serve:timing:${id}] session created at +${tCreated - tEntry}ms, waiting for ready...\n`,
)
await handle.waitReady(30000)
const tReady = Date.now()
process.stderr.write(
`[serve:timing:${id}] session ready at +${tReady - tEntry}ms\n`,
)
handle = await getOrResumeSession(id)
} catch (err) {
// Recovery failed — clear the busy status we emitted earlier
eventBus.publish('session.status', {
sessionID: id,
status: { type: 'idle' },
})
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
throw err
}
} else if (handle.prompting) {
throw conflict('session is already processing a prompt')
}
const tQueued = Date.now()
process.stderr.write(
`[serve:timing:${id}] session ready, queuing prompt at +${tQueued - tEntry}ms\n`,
)
void (async () => {
try {
const tFire = Date.now()
process.stderr.write(
`[serve:timing:${id}] IIFE starts, status=${handle.status} at +${tFire - tEntry}ms\n`,
)
if (handle.status !== 'running') {
await handle.waitReady()
const tReady = Date.now()
process.stderr.write(
`[serve:timing:${id}] IIFE waitReady done at +${tReady - tEntry}ms\n`,
)
}
if (body.model?.modelID) {
const tModelStart = Date.now()
try { await handle.setModel(body.model.modelID) } catch {}
const tModelDone = Date.now()
process.stderr.write(
`[serve:timing:${id}] setModel done at +${tModelDone - tEntry}ms (took ${tModelDone - tModelStart}ms)\n`,
)
}
const effectiveAgent = body.agent ?? (agentParts[0] as Record<string, unknown> | undefined)?.name as string | undefined
if (effectiveAgent && effectiveAgent !== handle.agent) {
try { await handle.setAgent(effectiveAgent) } catch {}
}
const tPromptCall = Date.now()
process.stderr.write(
`[serve:timing:${id}] calling prompt() at +${tPromptCall - tEntry}ms\n`,
)
handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {})
} catch {}
@ -536,51 +503,96 @@ export function createSessionRoutes(
.post('/session/:sessionID/abort', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
if (!handle) return c.json({ aborted: true })
await handle.abort()
return c.json({ aborted: true })
})
.post('/session/:sessionID/shell', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const handle = await getOrResumeSession(id)
const body = await c.req.json<{ command: string }>()
if (!body.command) throw badRequest('command is required')
return ssePrompt(handle, id, body.command, c)
})
.post('/session/:sessionID/command', async c => {
const t0 = Date.now()
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const body = await c.req.json<{ command: string }>()
const body = await c.req.json<{ command: string; arguments?: string; agent?: string; model?: string }>()
if (!body.command) throw badRequest('command is required')
return ssePrompt(handle, id, body.command, c)
const cmdParts = '/' + [body.command, body.arguments].filter(Boolean).join(' ')
eventBus.publish('session.status', {
sessionID: id,
status: { type: 'busy' },
})
let handle = sessionManager.getSession(id)
if (!handle) {
void (async () => {
try {
handle = await getOrResumeSession(id)
if (body.agent && body.agent !== handle.agent) {
try { await handle.setAgent(body.agent) } catch {}
}
if (body.model) {
const modelID = body.model.includes('/') ? body.model.split('/').slice(1).join('/') : body.model
try { await handle.setModel(modelID) } catch {}
}
handle.prompt(cmdParts).catch(() => {})
} catch (err) {
eventBus.publish('session.status', {
sessionID: id,
status: { type: 'idle' },
})
}
})()
return c.json({ ok: true }, 200)
}
if (body.agent && body.agent !== handle.agent) {
try { await handle.setAgent(body.agent) } catch {}
}
if (body.model) {
const modelID = body.model.includes('/') ? body.model.split('/').slice(1).join('/') : body.model
try { await handle.setModel(modelID) } catch {}
}
handle.prompt(cmdParts).catch(() => {})
return c.json({ ok: true }, 200)
})
.post('/session/:sessionID/command_async', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const handle = await getOrResumeSession(id)
const body = await c.req.json<{ command: string }>()
if (!body.command) throw badRequest('command is required')
if (handle.prompting) throw conflict('session is already processing a prompt')
eventBus.publish('session.status', {
sessionID: id,
status: { type: 'busy' },
})
handle.prompt(body.command).catch(() => {})
return new Response(null, { status: 204 })
return c.json({ ok: true }, 200)
})
.post('/session/:sessionID/revert', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const handle = await getOrResumeSession(id)
return c.json(handle.getInfo())
})
.post('/session/:sessionID/summarize', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
const handle = await getOrResumeSession(id)
return c.json({ ok: true })
})
}

View File

@ -7,6 +7,8 @@ import { INIT_TIMEOUT_MS, getScriptArgsForChild, loadChildSpawnPrefix } from './
import { ControlChannel } from './sessionControlChannel.js'
import { routeMessage, type StdoutMessage, type MessageRouterCtx } from './sessionMessageRouter.js'
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
import type { DisposableChildProcess } from '../utils/killOnDrop.js'
import type { SessionMessage } from './transcriptReader.js'
export type { InitData, PendingPermission, PendingQuestion }
export { getScriptArgsForChild }
@ -32,7 +34,7 @@ export type SessionHandleOptions = {
onInit?: (data: InitData) => void
}
export class SessionHandle {
export class SessionHandle implements DisposableChildProcess {
readonly sessionId: string
readonly cwd: string
private _spawnCwd: string | undefined
@ -68,6 +70,7 @@ export class SessionHandle {
private _controlChannel = new ControlChannel()
private _titleGenerationAttempted = false
private _firstPromptContent?: string
private _messageBuffer: SessionMessage[] = []
get status(): SessionState {
return this._status
@ -108,6 +111,17 @@ export class SessionHandle {
get prompting(): boolean {
return this._prompting
}
get messageBuffer(): ReadonlyArray<SessionMessage> {
return this._messageBuffer
}
pushMessage(msg: SessionMessage): void {
if (this._messageBuffer.length > 0) {
const last = this._messageBuffer[this._messageBuffer.length - 1]
if (last.uuid === msg.uuid) return
}
this._messageBuffer.push(msg)
}
get lastMessageUuid(): string | null {
return this._lastMessageUuid
@ -210,10 +224,12 @@ export class SessionHandle {
windowsHide: true,
})
// registerKillOnDrop(this, this.child)
this.setupStdout()
this.setupStderr()
this.child!.on('close', (code, signal) => {
// unregisterKillOnDrop(this)
if (this._status !== 'stopped') {
this._status = 'stopped'
this._busyStatus = { type: 'idle' }
@ -441,6 +457,7 @@ export class SessionHandle {
emitBusyStatus: () => this.emitBusyStatus(),
emitMessage: (msg) => this.emitMessage(msg),
writeStdin: (data) => this.writeStdin(data),
pushBufferMessage: (msg) => this.pushMessage(msg),
}
}
@ -485,19 +502,11 @@ export class SessionHandle {
}
async prompt(content: string, opts?: { parts?: Array<Record<string, unknown>>; messageID?: string }): Promise<{ done: boolean; error?: string }> {
const tEnter = Date.now()
if (this._status === 'stopped') {
throw new Error(
`Session ${this.sessionId} is stopped`,
)
}
if (this._status !== 'running') {
const tWaitStart = Date.now()
await this.waitReady()
process.stderr.write(
`[serve:timing:${this.sessionId}] prompt() waitReady took ${Date.now() - tWaitStart}ms\n`,
)
}
if (this._prompting) {
throw new Error(
`Session ${this.sessionId} is already processing a prompt`,
@ -505,12 +514,7 @@ export class SessionHandle {
}
this._prompting = true
// Busy status was already emitted from the route handler (immediate).
// Only emit here if this prompt() was called directly (not via prompt_async).
const alreadyBusy = this._busyStatus?.type === 'busy'
process.stderr.write(
`[serve:timing:${this.sessionId}] prompt() entry, alreadyBusy=${alreadyBusy}, +0ms from prompt() call\n`,
)
if (!alreadyBusy) {
this._busyStatus = { type: 'busy' }
@ -538,6 +542,15 @@ export class SessionHandle {
provider_id: this._providerId ?? '',
})
this.pushMessage({
uuid,
type: 'user',
role: 'user',
content,
timestamp: Date.now(),
parent_uuid: null,
})
this.writeStdin(userMsg)
if (!this._title && !this._titleGenerationAttempted) {
@ -695,6 +708,10 @@ export class SessionHandle {
this._controlChannel.rejectAll(new Error('Session force killed'))
}
[Symbol.dispose](): void {
this.forceKill()
}
getPendingPermissions(): PendingPermission[] {
return [...this.pendingPermissions.values()]
}

View File

@ -1,6 +1,7 @@
import { jsonStringify } from '../utils/slowOperations.js'
import type { ControlChannel } from './sessionControlChannel.js'
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
import type { SessionMessage } from './transcriptReader.js'
export type StdoutMessage = {
type: string
@ -42,6 +43,7 @@ export type MessageRouterCtx = {
emitBusyStatus(): void
emitMessage(msg: StdoutMessage): void
writeStdin(data: string): void
pushBufferMessage(msg: SessionMessage): void
}
export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
@ -132,6 +134,16 @@ function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void
enriched = { ...enriched, provider_id: ctx.getProviderId() }
}
ctx.emitEvent('message', enriched)
if (msg.uuid) {
ctx.pushBufferMessage({
uuid: msg.uuid as string,
type: 'assistant',
role: 'assistant',
content: msg.message ?? msg.content ?? '',
timestamp: msg.timestamp ? new Date(msg.timestamp as string).getTime() : Date.now(),
parent_uuid: (msg.parentUuid as string) ?? null,
})
}
}
function handleUserMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
@ -153,9 +165,6 @@ function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
ctx.setBusyStatus({ type: 'idle' })
ctx.emitBusyStatus()
process.stderr.write(
`[serve:timing:${ctx.sessionId}] result → idle emitted\n`,
)
ctx.emitEvent('result', msg)
ctx.resolvePrompt({ done: true })
}

94
src/utils/killOnDrop.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* kill_on_drop: Ensures child processes are killed when their handle is GC'd.
*
* Inspired by Rust's tokio `kill_on_drop(true)` pattern. Uses FinalizationRegistry
* to guarantee cleanup even when explicit kill() is missed (exception, bug, etc.).
*
* Two usage modes:
*
* 1. Class-based: implement `DisposableChildProcess` on your handle class, then
* call `registerKillOnDrop(this, this.child)`. When `this` is GC'd without
* `[Symbol.dispose]()` being called, the child is killed.
*
* 2. Scoped: use `killOnDrop(child)` with `using` keyword for block-scoped cleanup.
*/
import type { ChildProcess } from 'child_process'
import treeKill from 'tree-kill'
export interface DisposableChildProcess {
[Symbol.dispose](): void
}
const liveEntries = new WeakMap<DisposableChildProcess, { pid: number | undefined; killed: boolean }>()
const registry = new FinalizationRegistry<{ pid: number | undefined; killed: boolean }>((entry) => {
if (entry.pid && !entry.killed) {
try {
treeKill(entry.pid, 'SIGKILL')
} catch {
// Process may have already exited
}
}
})
/**
* Register a child process for automatic cleanup when the owner handle is GC'd.
*
* Call this after spawning. The child's 'exit' event auto-unregisters to
* avoid unnecessary finalization work.
*/
export function registerKillOnDrop(
owner: DisposableChildProcess,
child: ChildProcess,
): void {
const entry = { pid: child.pid, killed: child.killed }
registry.register(owner, entry, owner)
liveEntries.set(owner, entry)
child.once('exit', () => {
entry.killed = true
unregisterKillOnDrop(owner)
})
}
/**
* Unregister a handle from kill_on_drop. Called automatically when the child
* exits, or manually when you've explicitly killed the process.
*/
export function unregisterKillOnDrop(owner: DisposableChildProcess): void {
registry.unregister(owner)
liveEntries.delete(owner)
}
/**
* Scoped child process wrapper using `using` keyword.
* Guarantees kill on scope exit (normal or exception).
*
* @example
* ```ts
* using guard = killOnDrop(spawn(...))
* // ... use process ...
* // automatically killed when scope exits
* ```
*/
export function killOnDrop(child: ChildProcess): DisposableChildProcess {
let disposed = false
const wrapper: DisposableChildProcess = {
[Symbol.dispose]() {
if (disposed) return
disposed = true
if (child.pid && !child.killed) {
try {
treeKill(child.pid, 'SIGKILL')
} catch {
// already dead
}
}
},
}
child.once('exit', () => {
disposed = true
})
return wrapper
}

View File

@ -0,0 +1,163 @@
/**
* Permission mode hooks auto-configuration.
*
* Inspired by vibe-kanban's structured PermissionMode + Hooks mapping.
* Generates the `hooks` configuration for the CLI `initialize` control request
* based on the selected permission mode.
*
* Mode Hook mapping:
*
* | Mode | Hook behavior |
* |-------------------|-------------------------------------------------------------------|
* | plan | ExitPlanMode/AskUserQuestion ask; everything else auto-allow |
* | default (approvals) | Read-only tools auto-allow; rest ask |
* | acceptEdits | All file ops auto-allow; dangerous tools ask |
* | bypassPermissions | No hooks needed (all permissions skipped) |
* | auto | No hooks (classifier handles decisions) |
* | dontAsk | No hooks (askdeny conversion happens in permission pipeline) |
*/
import type { PermissionMode } from '../../types/permissions.js'
type HookMatcher = {
tool_name?: string
}
type HookAction = {
type: 'allow' | 'deny' | 'ask'
}
type HookConfig = {
matcher: HookMatcher
action: HookAction
}
type HooksConfig = {
PreToolUse?: HookConfig[]
PostToolUse?: HookConfig[]
Stop?: HookConfig[]
Notification?: Array<{ type: string }>
}
const READ_ONLY_TOOLS = [
'Read',
'Glob',
'Grep',
'ToolSearch',
'LSP',
'TaskGet',
'TaskList',
'WebFetch',
'WebSearch',
'McpGet',
'McpList',
]
const FILE_OPERATION_TOOLS = [
'Edit',
'Write',
'NotebookEdit',
]
const DANGEROUS_TOOLS = [
'Bash',
'PowerShell',
]
const PLAN_APPROVAL_TOOLS = [
'ExitPlanMode',
'AskUserQuestion',
]
function allowHook(toolName: string): HookConfig {
return { matcher: { tool_name: toolName }, action: { type: 'allow' } }
}
function askHook(toolName: string): HookConfig {
return { matcher: { tool_name: toolName }, action: { type: 'ask' } }
}
/**
* Build hooks configuration for a given permission mode.
* Returns the hooks object to pass in the CLI `initialize` control request.
* Returns `undefined` when no hooks are needed (bypass, auto, dontAsk).
*/
export function buildHooksForPermissionMode(mode: PermissionMode): HooksConfig | undefined {
switch (mode) {
case 'plan': {
const preToolUse: HookConfig[] = []
for (const tool of READ_ONLY_TOOLS) {
preToolUse.push(allowHook(tool))
}
for (const tool of FILE_OPERATION_TOOLS) {
preToolUse.push(allowHook(tool))
}
for (const tool of DANGEROUS_TOOLS) {
preToolUse.push(askHook(tool))
}
for (const tool of PLAN_APPROVAL_TOOLS) {
preToolUse.push(askHook(tool))
}
return { PreToolUse: preToolUse }
}
case 'default': {
const preToolUse: HookConfig[] = []
for (const tool of READ_ONLY_TOOLS) {
preToolUse.push(allowHook(tool))
}
for (const tool of FILE_OPERATION_TOOLS) {
preToolUse.push(askHook(tool))
}
for (const tool of DANGEROUS_TOOLS) {
preToolUse.push(askHook(tool))
}
return { PreToolUse: preToolUse }
}
case 'acceptEdits': {
const preToolUse: HookConfig[] = []
for (const tool of READ_ONLY_TOOLS) {
preToolUse.push(allowHook(tool))
}
for (const tool of FILE_OPERATION_TOOLS) {
preToolUse.push(allowHook(tool))
}
for (const tool of DANGEROUS_TOOLS) {
preToolUse.push(askHook(tool))
}
return { PreToolUse: preToolUse }
}
case 'bypassPermissions':
case 'auto':
case 'dontAsk':
case 'bubble':
return undefined
}
}
/**
* Merge auto-generated hooks with user-supplied hooks.
* User hooks take precedence (appended after auto-generated, CLI processes
* them in order first match wins in most implementations).
*/
export function mergeHooks(
autoHooks: HooksConfig | undefined,
userHooks: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!autoHooks && !userHooks) return undefined
if (!autoHooks) return userHooks
if (!userHooks) return autoHooks as unknown as Record<string, unknown>
const merged: Record<string, unknown> = { ...userHooks }
for (const [key, value] of Object.entries(autoHooks)) {
const existing = merged[key]
if (Array.isArray(existing) && Array.isArray(value)) {
merged[key] = [...value, ...existing]
} else if (!(key in merged)) {
merged[key] = value
}
}
return merged
}