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
11 changes: 7 additions & 4 deletions docs/architecture/memory-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ Maintenance 只处理有界 seed batch 和有界 same-scope vector neighbors;

## Maintenance 和可观测性

`MaintenanceService` 拥有 timer、cooldown、并发预算和 stop/drain;`MergeService` 只负责有界
`MaintenanceService` 拥有 timer、cooldown 和并发预算;`MemoryService` 统一暂停与排空;`MergeService` 只负责有界
near-duplicate merge,沿用 runner 传入的 operation fence、业务时间和共享预算。用户 conflict
resolution 的后续调度由 facade 负责;自动 challenge pass 每次成功应用后通知 Maintenance 调度,
即使后续 pair 失败也不丢失已经产生的调度。`ConflictService` 不持有 Maintenance 的构造依赖。
Expand All @@ -268,9 +268,12 @@ fence Memory、drain accepted work、关闭 store/SQLite、执行操作、reopen
`stopBackgroundMaintenance` 同步清空全部 prewarm/startup/consolidation timer、拒绝新的 arm 与 pass,
并对每个持有 in-flight pass 的 Agent 推进 execution fence、中止其 provider 请求,让 pass 及其委托的
challenge/merge/reflection/persona 子步骤在下一个 checkpoint 停止,而不是等完一个 provider deadline;
`drainBackgroundMaintenance` 在有界超时内等待这些 pass 落定,超时即让 database maintenance 失败而不是
带着未落定的 pass 关闭 SQLite。`startBackgroundMaintenance` 在 stop 之后可以重新 arm,startup pass
不会因此丢失。
`drainBackgroundMaintenance` 在一个有界超时内等待 consolidation、embedding/prewarm 和 clear work
落定,超时即让 database maintenance 失败而不是带着未落定的任务关闭 SQLite。暂停期间 dirty working
refresh 不访问数据库、不删除 projection,恢复后重新调度。Clear 在批次或 await 边界暂停,保留 durable
job 并拒绝未完成的请求;如果 drain 超时使数据库维护取消,恢复原数据库后仍在途的 clear 可以继续完成。
`startBackgroundMaintenance` 在 stop 之后恢复 admission、dirty refresh 和 pending clear,并重新 arm
startup pass。
启动恢复按 Agent 顺序处理 pending clear job,避免多个遗留 namespace 在同一个 event-loop tick
同时执行首批同步事务。Shutdown 只等待当前有界 batch;未完成 job 保持可恢复。

Expand Down
43 changes: 43 additions & 0 deletions docs/issues/memory-maintenance-drain/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Memory database-maintenance drain

## Cause and scope

The application closes SQLite after Memory's stop/drain handshake. That handshake only tracks
consolidation passes: started startup prewarms and durable clears can still access the repository
after drain reports success. Controlled async probes reproduced both continuations.

## Design

Keep the existing MemoryService boundary. Pause Memory admission in the runtime context before
stopping timers, invalidate existing execution fences and abort provider work. Drain accepted
consolidation, embedding and clear work under the existing shared timeout. Report pending Agent
IDs on timeout so the application refuses to close SQLite. New memory routes must be blocked
during the application's database-maintenance window, just like session routes.

Clear stops at a batch/await boundary with its durable job intact and rejects the interrupted
request rather than claiming completion. Resume admission and pending clears only after database
reopen. Preserve shutdown behavior, schema and public route contracts. Do not split agentMemory.ts
or introduce another scheduler, setting or dependency.

## Acceptance

- [x] Started prewarm cannot access SQLite after a successful drain.
- [x] Pending clear pauses durably and resumes after reopening.
- [x] Unsettled work is reported at the deadline; resume does not revive old execution fences.
- [x] Review P1/P2/P3, ablate unnecessary design, and run relevant and combined verification.

## Validation

Six maintenance regressions cover prewarm, clear batch boundaries, replacement databases,
blocked vector reset, early resume after a failed drain, and dirty working projections. All six
fail against the unchanged production baseline. Combined validation with the scoped retrieval
fix passes format, i18n, lint, application and Memory test typechecks, 951 behavior tests,
334 native tests (two skipped), 10 performance tests and seven evaluation tests.

Review found and fixed dirty projection access during pause. Simplification removes the separate
maintenance pause flag and drain implementation; accepted task maps and the shared deadline helper
remain the source of truth. Clear is not tied to model/config execution generations: a failed drain
prevents database replacement, so resuming the unchanged database may finish the accepted clear.
Recovery reloads pending clear fences from the reopened database rather than retaining stale jobs.

No push or GitHub issue sync is authorized.
1 change: 1 addition & 0 deletions src/main/app/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3361,6 +3361,7 @@ export async function createMainProcessControl(dependencies: {
if (
routeName.startsWith('chat.') ||
routeName.startsWith('sessions.') ||
routeName.startsWith('memory.') ||
routeName.startsWith('orchestration.') ||
routeName.startsWith('remoteControl.') ||
routeName.startsWith('cronJobs.')
Expand Down
38 changes: 29 additions & 9 deletions src/main/memory/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function isUniqueConstraintError(error: unknown): boolean {

export class MemoryRuntimeContext {
private disposed = false
private paused = false
private readonly readEpochByAgent = new Map<string, number>()
private readonly executionStateByAgent = new Map<string, MemoryExecutionState>()
private readonly pendingMemoryClearAgentIds: Set<string>
Expand All @@ -72,6 +73,21 @@ export class MemoryRuntimeContext {
return this.disposed
}

get isPaused(): boolean {
return this.paused
}

pause(): void {
this.paused = true
for (const agentId of this.executionStateByAgent.keys()) {
this.invalidateAgentOperations(agentId)
}
}

resume(): void {
this.paused = false
}

now(): number {
const now = this.clock.now()
if (!Number.isFinite(now)) {
Expand Down Expand Up @@ -102,6 +118,7 @@ export class MemoryRuntimeContext {
isOperationFenceCurrent(fence: MemoryOperationFence): boolean {
return (
!this.disposed &&
!this.paused &&
(this.executionStateByAgent.get(fence.agentId)?.generation ?? 0) === fence.generation
)
}
Expand Down Expand Up @@ -200,6 +217,11 @@ export class MemoryRuntimeContext {
return this.pendingMemoryClearAgentIds.has(agentId)
}

syncPendingMemoryClears(agentIds: readonly string[]): void {
this.pendingMemoryClearAgentIds.clear()
for (const agentId of agentIds) this.pendingMemoryClearAgentIds.add(agentId)
}

markMemoryClearPending(agentId: string): void {
this.pendingMemoryClearAgentIds.add(agentId)
}
Expand All @@ -211,22 +233,23 @@ export class MemoryRuntimeContext {
canWriteAgentMemory(agentId: string): boolean {
return (
!this.disposed &&
!this.paused &&
!this.isMemoryClearPending(agentId) &&
this.isManagedAgent(agentId) &&
this.isEnabled(agentId)
)
}

canManageAgentMemory(agentId: string): boolean {
return !this.disposed && this.isManagedAgent(agentId)
return !this.disposed && !this.paused && this.isManagedAgent(agentId)
}

canManageClaimMemory(agentId: string): boolean {
return this.canManageAgentMemory(agentId) && !this.isMemoryClearPending(agentId)
}

canReadDirectivePlane(agentId: string): boolean {
return !this.disposed && this.isManagedAgent(agentId) && this.isEnabled(agentId)
return this.canManageAgentMemory(agentId) && this.isEnabled(agentId)
}

canReadAgentMemory(agentId: string): boolean {
Expand All @@ -238,16 +261,13 @@ export class MemoryRuntimeContext {
}

canUseCurrentMemoryEmbedding(agentId: string, embedding: MemoryModelRef): boolean {
if (!this.canReadAgentMemory(agentId)) return false
const current = this.options.policy.resolveAgentConfig(agentId)?.memoryEmbedding
return (
current?.providerId === embedding.providerId &&
current?.modelId === embedding.modelId &&
this.canReadAgentMemory(agentId)
)
return current?.providerId === embedding.providerId && current?.modelId === embedding.modelId
}

emitChanged(agentId: string, reason: MemoryUpdateReason, context?: MemoryUpdateContext): void {
if (this.disposed) return
if (this.disposed || this.paused) return
this.options.onAgentMemoryMutated?.(agentId)
if (context) this.options.changeSink?.onMemoryChanged?.(agentId, reason, context)
else this.options.changeSink?.onMemoryChanged?.(agentId, reason)
Expand All @@ -267,7 +287,7 @@ export class MemoryRuntimeContext {
createdAt?: number
}
): void {
if (this.disposed) return
if (this.disposed || this.paused) return
this.options.auditWriter?.insert({
id: `audit-${nanoid(12)}`,
agentId,
Expand Down
34 changes: 31 additions & 3 deletions src/main/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ import type {
MemoryPersonaDraftResult,
MemoryReflectionResult
} from './types'
import { REINDEX_MAX_BATCHES } from './runtimeConstants'
import { MAINTENANCE_DRAIN_TIMEOUT_MS, REINDEX_MAX_BATCHES } from './runtimeConstants'
import { withSoftDeadline } from './core/asyncDeadline'
import { MemoryRuntimeContext } from './context'
import { MemoryRowMutations } from './services/rowMutations'
import { VectorStoreManager } from './infra/vectorStoreManager'
Expand Down Expand Up @@ -375,18 +376,45 @@ export class MemoryService implements MemoryRuntimePort {
}

startBackgroundMaintenance(): void {
if (this.runtime.isDisposed) return
this.runtime.resume()
this.workingMemory.resumeDirtyRefreshes()
void this.management.resumePendingMemoryClears().catch((error) => {
logger.error(`[Memory] pending clear recovery failed: ${String(error)}`)
})
this.maintenance.startBackgroundMaintenance()
}

stopBackgroundMaintenance(): void {
this.runtime.pause()
this.maintenance.stopBackgroundMaintenance()
}

drainBackgroundMaintenance(timeoutMs?: number): Promise<string[]> {
return this.maintenance.drainBackgroundMaintenance(timeoutMs)
async drainBackgroundMaintenance(
timeoutMs: number = MAINTENANCE_DRAIN_TIMEOUT_MS
): Promise<string[]> {
const deadline = performance.now() + timeoutMs
while (true) {
const pending = [
...this.maintenance.getInFlight(),
...this.embedding.getInFlight(),
...this.management.getInFlightMemoryClears()
]
if (!pending.length) return []
const result = await withSoftDeadline(
Promise.allSettled(pending),
Math.max(0, deadline - performance.now())
)
if (result.timedOut) {
return [
...new Set([
...this.maintenance.getInFlightAgentIds(),
...this.embedding.getInFlightAgentIds(),
...this.management.getInFlightClearAgentIds()
])
].sort()
}
}
}

warmActiveAgents(): void {
Expand Down
12 changes: 12 additions & 0 deletions src/main/memory/infra/embeddingPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,18 @@ export class EmbeddingPipeline {
this.embeddingWarmups.set(key, tracked)
}

getInFlightAgentIds(): string[] {
return [
...new Set([
...this.reindexing.keys(),
...this.backfilling.keys(),
...this.embeddingDrains.keys(),
...[...this.vectorStoreWarmups.keys()].map((key) => key.split('::')[0]),
...[...this.embeddingWarmupAgents.values()].flatMap((agents) => [...agents])
])
]
}

getInFlight(): Promise<unknown>[] {
return [
...this.reindexing.values(),
Expand Down
32 changes: 8 additions & 24 deletions src/main/memory/services/maintenanceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
CONSOLIDATION_DIRTY_SEED_LIMIT,
CONSOLIDATION_FAILURE_COOLDOWN_MS,
CONSOLIDATION_IDLE_MS,
MAINTENANCE_DRAIN_TIMEOUT_MS,
MAINTENANCE_HEAVY_MAX_CONCURRENCY,
MAINTENANCE_START_DELAY_MS,
STARTUP_ARM_STAGGER_MS,
Expand Down Expand Up @@ -67,7 +66,6 @@ export class MaintenanceService {
private prewarmStartTimer: NodeJS.Timeout | null = null
private readonly prewarmTimers = new Map<string, NodeJS.Timeout>()
private maintenanceStarted = false
private maintenancePaused = false

// Heavy passes run in this order under one shared budget; each is fenced independently so a
// stop request lands at the next boundary instead of after the whole sequence.
Expand Down Expand Up @@ -205,7 +203,6 @@ export class MaintenanceService {
startBackgroundMaintenance(): void {
if (this.ctx.isDisposed || this.maintenanceStarted) return
this.maintenanceStarted = true
this.maintenancePaused = false
this.prewarmStartTimer = setTimeout(() => {
this.prewarmStartTimer = null
if (this.ctx.isDisposed) return
Expand All @@ -226,12 +223,11 @@ export class MaintenanceService {
* fence invalidated and its provider requests aborted, so the pass and the
* sub-services it delegates to stop at their next checkpoint instead of
* waiting out a provider deadline. `startBackgroundMaintenance` re-arms after
* the caller's maintenance window; `drainBackgroundMaintenance` waits for the
* fenced passes to settle.
* the caller's maintenance window. MemoryService drains these passes together
* with embedding and clear work before the database can close.
*/
stopBackgroundMaintenance(): void {
this.maintenanceStarted = false
this.maintenancePaused = true
if (this.prewarmStartTimer) {
clearTimeout(this.prewarmStartTimer)
this.prewarmStartTimer = null
Expand All @@ -250,22 +246,6 @@ export class MaintenanceService {
}
}

/** Waits for in-flight passes and returns the agents whose pass is still running. */
async drainBackgroundMaintenance(
timeoutMs: number = MAINTENANCE_DRAIN_TIMEOUT_MS
): Promise<string[]> {
let timer: ReturnType<typeof setTimeout> | undefined
await Promise.race([
Promise.allSettled(this.consolidationPasses.values()),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
if (typeof timer.unref === 'function') timer.unref()
})
])
if (timer) clearTimeout(timer)
return [...this.consolidationPasses.keys()].sort()
}

prepareDispose(): void {
this.stopBackgroundMaintenance()
this.lastConsolidationAt.clear()
Expand Down Expand Up @@ -359,7 +339,7 @@ export class MaintenanceService {
delayMs: number = CONSOLIDATION_IDLE_MS,
options: { preserveEarlier?: boolean } = {}
): void {
if (this.ctx.isDisposed || this.maintenancePaused) return
if (this.ctx.isDisposed || this.ctx.isPaused) return
const dueAt = Date.now() + delayMs
const existing = this.consolidationTimers.get(agentId)
const existingDueAt = this.consolidationTimerDueAt.get(agentId)
Expand Down Expand Up @@ -389,7 +369,7 @@ export class MaintenanceService {
const effectiveNow = now ?? this.ctx.now()
const existing = this.consolidationPasses.get(agentId)
if (existing) return existing
if (this.maintenancePaused) return
if (this.ctx.isPaused) return
const tracked = this.runConsolidationPassInternal(agentId, effectiveNow).finally(() => {
if (this.consolidationPasses.get(agentId) === tracked) {
this.consolidationPasses.delete(agentId)
Expand Down Expand Up @@ -643,6 +623,10 @@ export class MaintenanceService {
return [...this.consolidationPasses.values()]
}

getInFlightAgentIds(): string[] {
return [...this.consolidationPasses.keys()]
}

clearInFlight(): void {
this.consolidationPasses.clear()
}
Expand Down
12 changes: 12 additions & 0 deletions src/main/memory/services/managementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export class ManagementService {
}

private enqueueMemoryClear(agentId: string): Promise<MemoryClearResult> {
if (this.ctx.isPaused) {
return Promise.reject(new Error('[Memory] clear paused for database maintenance'))
}
const existing = this.clearOperations.get(agentId)
if (existing) return existing
const operation = this.runMemoryClear(agentId)
Expand All @@ -184,9 +187,16 @@ export class ManagementService {
return [...this.clearOperations.values()]
}

getInFlightClearAgentIds(): string[] {
return [...this.clearOperations.keys()]
}

async resumePendingMemoryClears(): Promise<void> {
const jobs = this.ports.repository.listPendingMemoryClearJobs()
// Database maintenance may replace SQLite rather than reopen the same file.
this.ctx.syncPendingMemoryClears(jobs.map((job) => job.agentId))
for (const job of jobs) {
if (this.ctx.isPaused || this.ctx.isDisposed) return
if (!isSafeAgentId(job.agentId)) {
logger.error(`[Memory] refusing to resume clear job with invalid agent id: ${job.agentId}`)
continue
Expand Down Expand Up @@ -729,6 +739,7 @@ export class ManagementService {
this.ports.syncWorkingMemoryAfterMutation(agentId)

while (job.phase === 'claims') {
if (this.ctx.isPaused) throw new Error('[Memory] clear paused for database maintenance')
const batch = this.ports.repository.processMemoryClearBatch(agentId)
if (!batch) {
const remaining = this.ports.repository.countByAgent(agentId)
Expand Down Expand Up @@ -776,6 +787,7 @@ export class ManagementService {
return { removed: job.removed, cleanupPendingRestart }
}

if (this.ctx.isPaused) throw new Error('[Memory] clear paused for database maintenance')
if (!this.ports.repository.completeMemoryClear(agentId)) {
throw new Error(`[Memory] clear job returned to claim cleanup for ${agentId}`)
}
Expand Down
Loading