本章目录In this chapter
- 2 · The Session Layer: QueryEngine
- 2.1 The problem it solves
- 2.2 What state it holds
- 2.3 The full flow of one submitMessage
- 2.4 Why the user message must hit disk first
- 2.5 Consuming the main loop’s output: one big switch
- 2.6 The compact boundary: proactively releasing memory
- 2.7 Three kinds of exit result
- 2.8 ask(): a convenience wrapper for one-shot calls
2 · 会话层:QueryEngine
这一章讲「一场对话」这个东西在程序里是怎么被表示和管理的。
2.1 它解决什么问题
大语言模型没有记忆 —— 每次调用都要把全部历史重发一遍。所以必须有个东西持有这场对话的所有状态,在用户的每一次提问之间保持存活。
这就是 QueryEngine(查询引擎)。源码里的类注释说得很清楚:
「QueryEngine owns the query lifecycle and session state for a conversation. One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage, etc.) persists across turns.」
译:QueryEngine 持有一场对话的查询生命周期和会话状态。一场对话对应一个 QueryEngine 实例。每次调用 submitMessage() 就在同一场对话里开启一个新轮次。状态(消息、文件缓存、用量等)跨轮次保留。
2.2 它持有哪些状态
export class QueryEngine {
private config: QueryEngineConfig // 不变的配置(工具、命令、模型等)
private mutableMessages: Message[] // ★ 完整的消息历史,会一直增长
private abortController: AbortController // 中止开关,贯穿整条调用链
private permissionDenials: SDKPermissionDenial[] // 被拒绝过的操作记录
private totalUsage: NonNullableUsage // 累计 token 用量
private hasHandledOrphanedPermission = false
private readFileState: FileStateCache // ★ 读过哪些文件、什么版本
// 下面两个是"轮次内追踪",每轮开头清空
private discoveredSkillNames = new Set<string>() // 本轮发现了哪些技能
private loadedNestedMemoryPaths = new Set<string>() // 本轮加载了哪些记忆文件
}
claude-code/src/QueryEngine.ts
其中两个字段值得单独说
readFileState(文件读取状态缓存)记录「模型读过哪些文件、读的是哪个版本」。它有三个用途:
- 防止重复注入。记忆系统预取到一个文件时,如果模型自己已经读过它,就不再作为「记忆」注入一遍。
- 检测文件被外部修改。模型读过
a.ts,后来用户在编辑器里改了它,那么模型手里的内容就过期了。系统会检测到并注入一条提示。 - 编辑前的安全检查。模型要改一个它从没读过的文件时,工具会拒绝并要求它先读 —— 因为盲改极易出错。
abortController(中止控制器)是那个「取消开关」。它被传递到每一个工具调用、每一个网络请求。用户按 Ctrl+C 时拉一下,整条链路都能感知到。第 3 章会讲它的正确处理姿势。
2.3 一次 submitMessage 的完整流程
submitMessage() 是这个类的核心方法。它是一个异步生成器 —— 也就是说它不是「算完再返回」,而是一边算一边往外吐消息,调用方可以实时消费。
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>
(async * 是 JavaScript 的异步生成器语法。yield 一个值就等于「先把这个吐出去,调用方拿到之后我再继续」。这是流式界面能实时更新的基础。)
完整流程:
2.4 用户消息为什么必须先落盘
第 ⑤ 步有一段很长的注释,讲的是一个真实的线上问题:
「Persist the user's message(s) to transcript BEFORE entering the query loop. The for-await below only calls recordTranscript when ask() yields an assistant/user/compact_boundary message — which doesn't happen until the API responds. If the process is killed before that (e.g. user clicks Stop in cowork seconds after send), the transcript is left with only queue-operation entries; getLastSessionLog filters those out, returns null, and --resume fails with "No conversation found".」
译:在进入查询循环之前就把用户消息写入对话记录。因为下面那个循环只有在生成器吐出模型消息 / 用户消息 / 压缩分界点消息时才会调用记录函数 —— 而这要等到接口响应回来才会发生。如果进程在那之前就被杀掉(比如用户点了发送之后几秒就点了停止),对话记录里就只剩下队列操作条目;而读取上次会话记录的函数会把这些过滤掉、返回空,于是 --resume 会报「找不到对话」。
翻译成人话:用户发出消息后、模型还没回复的那几秒钟里,如果程序被杀掉,这次对话就彻底找不回来了。因为落盘的时机在模型回复之后。
修复方式是把落盘提前到「用户消息被接受」的那一刻。但这里又冒出一个性能权衡:
if (persistSession && messagesFromUserInput.length > 0) {
const transcriptPromise = recordTranscript(messages)
if (isBareMode()) {
void transcriptPromise // ★ 极简模式:发射后不管,不等它写完
} else {
await transcriptPromise // 正常模式:等写完再继续
...
}
}
注释解释了为什么极简模式要特殊处理:
「--bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention — the single largest controllable critical-path cost after module eval.」
译:极简模式下发射后不管。脚本化调用不会在请求中途被杀之后去恢复。这个 await 在固态硬盘上约 4 毫秒,在磁盘竞争时约 30 毫秒 —— 是继模块加载之后关键路径上最大的可控开销。
这句话的信息量很大:他们把关键路径上的每一项开销都量化过,4 到 30 毫秒已经是「最大的可控开销」了。
2.5 消费主循环输出:一个大 switch
进入主循环后,QueryEngine 用一个 for await 循环消费主循环吐出的每一条消息,按类型分别处理:
| 消息类型 | QueryEngine 做什么 |
|---|---|
assistant模型回复 | 记录停止原因、追加到历史、用「发射后不管」的方式落盘(原因见下)、转换成标准格式吐给调用方 |
user用户消息 / 工具结果 | 追加到历史、同步等待落盘、轮次计数 +1 |
progress进度 | 追加到历史并立刻落盘(原因见下) |
attachment附件 | 追加、立刻落盘。如果是「结构化输出」附件则提取结果;如果是「达到最大轮次」则发一个错误结果并返回 |
stream_event流式事件 | 累计 token 用量。只有开了 --include-partial-messages 才吐给调用方 |
system系统消息 | 压缩分界点 → 释放分界点之前的消息供垃圾回收;接口错误 → 转成重试通知 |
tombstone墓碑 | 控制信号,表示「删除某条消息」,直接跳过不处理 |
tool_use_summary工具摘要 | 转发给调用方(用于移动端界面显示「刚才做了什么」) |
为什么模型消息要「发射后不管」,而用户消息要同步等待
「Fire-and-forget for assistant messages. claude.ts yields one assistant message per content block, then mutates the last one's message.usage/stop_reason on message_delta — relying on the write queue's 100ms lazy jsonStringify. Awaiting here blocks ask()'s generator, so message_delta can't run until every block is consumed; the drain timer (started at block 1) elapses first.」
译:模型消息用发射后不管。接口层为每个内容块吐出一条模型消息,然后在收到 message_delta 事件时修改最后那条消息的用量和停止原因字段 —— 这依赖写入队列 100 毫秒的延迟序列化。如果在这里等待,就会阻塞生成器,导致 message_delta 事件要等所有内容块被消费完才能处理;而排空定时器(从第 1 个块就开始计时了)会先到期。
这一段涉及一个精巧的机制,值得展开:
而 progress(进度)消息要「立刻落盘」,也有专门的注释:
「Record inline so the dedup loop in the next ask() call sees it as already-recorded. Without this, deferred progress interleaves with already-recorded tool_results in mutableMessages, and the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.」
译:就地记录,这样下一次调用时的去重循环才能看到它已被记录。否则延迟的进度消息会和已记录的工具结果交错,导致去重遍历把「起始父节点」固定在错误的消息上 —— 从而分叉出一条支链,让对话在恢复时变成孤儿。
这里透露了对话记录的一个重要结构:它不是一个线性列表,而是一棵通过「父节点 ID」串起来的树。第 11 章会详细讲。
2.6 压缩分界点:主动释放内存
当主循环发出「压缩分界点」消息时,QueryEngine 做一件很重要的事:
if (message.subtype === 'compact_boundary' && message.compactMetadata) {
// 分界点之前的消息已经被摘要替代了,可以释放给垃圾回收器
const mutableBoundaryIdx = this.mutableMessages.length - 1
if (mutableBoundaryIdx > 0) {
this.mutableMessages.splice(0, mutableBoundaryIdx) // ★ 直接从数组里删掉
}
const localBoundaryIdx = messages.length - 1
if (localBoundaryIdx > 0) {
messages.splice(0, localBoundaryIdx)
}
yield { type:'system', subtype:'compact_boundary', ... }
}
注释:「Release pre-compaction messages for GC. query.ts already uses getMessagesAfterCompactBoundary() internally, so only post-boundary messages are needed going forward.」(把压缩前的消息释放给垃圾回收。主循环内部已经只用分界点之后的消息了,所以往后只需要保留这些。)
为什么要专门做这件事?因为一场长会话的消息历史可能有几百 MB。压缩之后前面那些消息在逻辑上已经没用了,但只要数组还引用着它们,垃圾回收器就不会回收 —— 内存会一直涨到进程被系统杀掉。
但落盘要在释放之前完成
而且顺序不能错。在删除之前,有一段专门的落盘逻辑:
if (persistSession && message.type === 'system' &&
message.subtype === 'compact_boundary') {
const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
if (tailUuid) {
const tailIdx = this.mutableMessages.findLastIndex(m => m.uuid === tailUuid)
if (tailIdx !== -1) {
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
}
}
}
注释解释了不这么做的后果:「If the SDK subprocess restarts before then (claude-desktop kills between turns), tailUuid points to a never-written message → applyPreservedSegmentRelinks fails its tail→head walk → returns without pruning → resume loads full pre-compact history.」
译:如果子进程在那之前重启(桌面应用会在轮次之间杀进程),保留段的尾节点就指向了一条从未被写入的消息 → 重新串联函数的「从尾到头」遍历失败 → 直接返回不做裁剪 → 于是恢复时会加载完整的压缩前历史。
症状是:用户压缩过的会话,恢复之后又变回了压缩前的样子,上下文立刻爆掉。
2.7 三种退出结果
submitMessage 最终会发出一个 result 消息,标明这次轮次是怎么结束的:
| 结果类型 | 什么时候发生 |
|---|---|
success | 正常完成 |
error_max_turns | 达到 --max-turns 上限 |
error_max_budget_usd | 达到 --max-budget-usd 上限 |
error_max_structured_output_retries | 要求结构化输出,但模型连续 5 次都产出不合格的结果 |
error_during_execution | 执行过程中出了没能恢复的错 |
最后那个错误类型带了一个专门的诊断前缀
errors: (() => {
const all = getInMemoryErrors()
const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
return [
// ★ 诊断前缀:直接说明"判定失败"的那三个条件各自是什么值
`[ede_diagnostic] result_type=${edeResultType} ` +
`last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
...all.slice(start).map(_ => _.error),
]
})()
而且错误列表是按轮次范围截取的 —— 用了一个「水位标记」:
// 用引用而不是下标作为水位标记,这样 error_during_execution 的 errors 数组
// 是轮次范围内的。用长度下标会在 100 条环形缓冲区发生位移时失效 —— 下标会滑走。
// 如果这条标记本身被轮换出去了,lastIndexOf 返回 -1,就包含全部(安全兜底)。
const errorLogWatermark = getInMemoryErrors().at(-1)
内存里的错误日志是一个只保留最近 100 条的环形缓冲区。想标记「本轮开始的位置」,最直觉的做法是记下当时的数组长度。
但环形缓冲区在满了之后会从头部丢弃元素 —— 你记的那个下标会「滑走」,指向别的位置。
正确做法是记住那个元素本身的引用,之后用 lastIndexOf 反查它现在在哪。如果它已经被挤出去了,反查返回 -1,代码就退化为「包含全部错误」—— 这是一个安全的降级行为,宁可多报也不漏报。
2.8 ask():一次性调用的便捷封装
文件末尾还导出了一个 ask() 函数,是 QueryEngine 的一次性封装 —— 创建实例、跑一轮、把文件缓存交还给调用方:
export async function* ask({...}) {
const engine = new QueryEngine({
...,
readFileCache: cloneFileStateCache(getReadFileCache()), // ★ 传入的是克隆
})
try {
yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
} finally {
setReadFileCache(engine.getReadFileState()) // ★ 无论如何都要交还
}
}
两个细节:
- 传入的文件缓存是克隆的。这样这次调用对缓存的修改不会立刻影响外部 —— 直到最后显式交还。
finally块保证交还。即使中间抛异常、被中断,「模型读过哪些文件」这个信息也不会丢。丢了会导致下一轮重复注入记忆或者误判文件新鲜度。
2 · The Session Layer: QueryEngine
This chapter covers how “a conversation” is represented and managed inside the program.
2.1 The problem it solves
A large language model has no memory — every call has to resend the entire history. So something must hold all the state of the conversation and stay alive between one user question and the next.
That something is QueryEngine. The class comment in the source puts it plainly:
“QueryEngine owns the query lifecycle and session state for a conversation. One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage, etc.) persists across turns.”
In other words: QueryEngine owns the query lifecycle and session state for one conversation. One conversation maps to one QueryEngine instance. Every call to submitMessage() opens a new turn within the same conversation. State (messages, file cache, usage, and so on) carries over across turns.
2.2 What state it holds
export class QueryEngine {
private config: QueryEngineConfig // immutable configuration (tools, commands, model, etc.)
private mutableMessages: Message[] // ★ the full message history; grows forever
private abortController: AbortController // the abort switch, threaded through the entire call chain
private permissionDenials: SDKPermissionDenial[] // record of operations that were denied
private totalUsage: NonNullableUsage // cumulative token usage
private hasHandledOrphanedPermission = false
private readFileState: FileStateCache // ★ which files have been read, and which version
// The two below are "per-turn tracking," cleared at the start of each turn
private discoveredSkillNames = new Set<string>() // skills discovered this turn
private loadedNestedMemoryPaths = new Set<string>() // memory files loaded this turn
}
claude-code/src/QueryEngine.ts
Two of these fields deserve a closer look
readFileState (the file-read state cache) records “which files the model has read, and which version.” It serves three purposes:
- Preventing duplicate injection. When the memory system prefetches a file, and the model has already read it on its own, it is not injected again as “memory.”
- Detecting external modification. The model read
a.ts; later the user changed it in their editor; now the content the model is holding is stale. The system detects this and injects a notice. - A safety check before editing. When the model tries to modify a file it has never read, the tool refuses and tells it to read first — because editing blind is a recipe for mistakes.
abortController is the “cancel switch.” It is passed into every tool call and every network request. When the user presses Ctrl+C, it is pulled, and the whole chain can feel it. Chapter 3 covers how to handle it correctly.
2.3 The full flow of one submitMessage
submitMessage() is the core method of this class. It is an async generator — meaning it does not “finish computing, then return”; it emits messages while it works, and the caller can consume them in real time.
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>
(async * is JavaScript’s async generator syntax. To yield a value means “hand this out first; once the caller has it, I continue.” This is the foundation that lets a streaming interface update live.)
The full flow:
2.4 Why the user message must hit disk first
Step ⑤ carries a long comment describing a real production issue:
“Persist the user's message(s) to transcript BEFORE entering the query loop. The for-await below only calls recordTranscript when ask() yields an assistant/user/compact_boundary message — which doesn't happen until the API responds. If the process is killed before that (e.g. user clicks Stop in cowork seconds after send), the transcript is left with only queue-operation entries; getLastSessionLog filters those out, returns null, and --resume fails with "No conversation found".”
In other words: write the user message to the transcript before entering the query loop. The loop below only calls the recording function when the generator emits an assistant / user / compact-boundary message — and that does not happen until the API responds. If the process is killed before then (say the user hits Send and then Stop a few seconds later), the transcript is left with nothing but queue-operation entries; the function that reads the last session log filters those out and returns null, so --resume fails with “No conversation found.”
In plain English: if the program is killed during the few seconds between the user sending a message and the model replying, that conversation is gone for good. Because the write to disk happened only after the model’s reply.
The fix is to move the write forward to the moment “the user message is accepted.” But that surfaces a performance trade-off:
if (persistSession && messagesFromUserInput.length > 0) {
const transcriptPromise = recordTranscript(messages)
if (isBareMode()) {
void transcriptPromise // ★ bare mode: fire and forget; don't wait for the write
} else {
await transcriptPromise // normal mode: wait for the write before continuing
...
}
}
The comment explains why bare mode gets special treatment:
“--bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention — the single largest controllable critical-path cost after module eval.”
In other words: in bare mode, fire and forget. Scripted calls never resume after being killed mid-request. That await costs about 4 ms on an SSD and about 30 ms under disk contention — the single largest controllable cost on the critical path after module loading.
That sentence carries a lot of information: they have quantified every cost on the critical path, and 4 to 30 milliseconds already counts as “the largest controllable cost.”
2.5 Consuming the main loop’s output: one big switch
Once inside the main loop, QueryEngine uses a for await loop to consume each message the main loop emits, handling each type differently:
| Message type | What QueryEngine does |
|---|---|
assistantmodel reply | Records the stop reason, appends to history, writes to disk fire-and-forget (reason below), converts to the standard format and emits to the caller |
useruser message / tool result | Appends to history, waits synchronously for the disk write, turn count +1 |
progressprogress | Appends to history and writes to disk immediately (reason below) |
attachmentattachment | Appends, writes immediately. If it is a “structured output” attachment, extracts the result; if it is “max turns reached,” emits an error result and returns |
stream_eventstreaming event | Accumulates token usage. Emitted to the caller only if --include-partial-messages is on |
systemsystem message | Compact boundary → releases the messages before the boundary for garbage collection; API error → converted into a retry notification |
tombstonetombstone | A control signal meaning “delete a given message”; skipped without processing |
tool_use_summarytool summary | Forwarded to the caller (used by the mobile UI to show “what just happened”) |
Why model messages are fire-and-forget while user messages wait synchronously
“Fire-and-forget for assistant messages. claude.ts yields one assistant message per content block, then mutates the last one's message.usage/stop_reason on message_delta — relying on the write queue's 100ms lazy jsonStringify. Awaiting here blocks ask()'s generator, so message_delta can't run until every block is consumed; the drain timer (started at block 1) elapses first.”
In other words: model messages are fire-and-forget. The API layer emits one model message per content block, then, when the message_delta event arrives, mutates the usage and stop-reason fields of that last message — relying on the write queue’s 100 ms lazy serialization. Awaiting here would block the generator, so message_delta could not be processed until every content block had been consumed; the drain timer (started at block 1) would expire first.
There is a subtle mechanism in here that is worth unpacking:
The progress message’s “write immediately” also has a dedicated comment:
“Record inline so the dedup loop in the next ask() call sees it as already-recorded. Without this, deferred progress interleaves with already-recorded tool_results in mutableMessages, and the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.”
In other words: record it inline, so the dedup loop in the next call sees it as already recorded. Otherwise deferred progress messages interleave with already-recorded tool results, and the dedup walk pins the “starting parent node” to the wrong message — forking off a side branch and orphaning the conversation on resume.
This reveals an important structural fact about the transcript: it is not a linear list; it is a tree linked together by “parent node IDs.” Chapter 11 covers this in detail.
2.6 The compact boundary: proactively releasing memory
When the main loop emits a “compact boundary” message, QueryEngine does something important:
if (message.subtype === 'compact_boundary' && message.compactMetadata) {
// Messages before the boundary have been replaced by a summary; release them to the garbage collector
const mutableBoundaryIdx = this.mutableMessages.length - 1
if (mutableBoundaryIdx > 0) {
this.mutableMessages.splice(0, mutableBoundaryIdx) // ★ delete them from the array outright
}
const localBoundaryIdx = messages.length - 1
if (localBoundaryIdx > 0) {
messages.splice(0, localBoundaryIdx)
}
yield { type:'system', subtype:'compact_boundary', ... }
}
Comment: “Release pre-compaction messages for GC. query.ts already uses getMessagesAfterCompactBoundary() internally, so only post-boundary messages are needed going forward.” (Release the pre-compaction messages to the garbage collector. The main loop already only uses messages after the boundary internally, so only those need to be kept from here on.)
Why go out of the way to do this? Because the message history of a long session can run to hundreds of MB. After compaction, the earlier messages are logically useless, but as long as the array still references them, the garbage collector will not reclaim them — memory keeps climbing until the OS kills the process.
But the disk write must finish before the release
And the order cannot be wrong. Right before the deletion, there is a dedicated piece of disk-write logic:
if (persistSession && message.type === 'system' &&
message.subtype === 'compact_boundary') {
const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
if (tailUuid) {
const tailIdx = this.mutableMessages.findLastIndex(m => m.uuid === tailUuid)
if (tailIdx !== -1) {
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
}
}
}
The comment explains the consequence of not doing this: “If the SDK subprocess restarts before then (claude-desktop kills between turns), tailUuid points to a never-written message → applyPreservedSegmentRelinks fails its tail→head walk → returns without pruning → resume loads full pre-compact history.”
In other words: if the subprocess restarts before then (the desktop app kills the process between turns), the tail node of the preserved segment points to a message that was never written → the relinking function’s tail-to-head walk fails → it returns without pruning → and resume loads the full pre-compaction history.
The symptom: a session the user had compacted comes back after resume looking the way it did before compaction, and the context blows up immediately.
2.7 Three kinds of exit result
submitMessage ends by emitting a result message indicating how the turn finished:
| Result type | When it happens |
|---|---|
success | Completed normally |
error_max_turns | Hit the --max-turns limit |
error_max_budget_usd | Hit the --max-budget-usd limit |
error_max_structured_output_retries | Structured output was requested, but the model produced a non-conforming result 5 times in a row |
error_during_execution | An unrecoverable error occurred during execution |
That last error type carries a dedicated diagnostic prefix
errors: (() => {
const all = getInMemoryErrors()
const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
return [
// ★ Diagnostic prefix: states outright what the three "failure verdict" conditions each evaluated to
`[ede_diagnostic] result_type=${edeResultType} ` +
`last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
...all.slice(start).map(_ => _.error),
]
})()
And the error list is sliced to the range of this turn — using a “watermark”:
// Use a reference, not an index, as the watermark, so the errors array for error_during_execution
// is scoped to this turn. A length index breaks when the 100-entry ring buffer shifts — the index slides away.
// If the marker itself has been rotated out, lastIndexOf returns -1 and we include everything (safe fallback).
const errorLogWatermark = getInMemoryErrors().at(-1)
The in-memory error log is a ring buffer that keeps only the most recent 100 entries. To mark “where this turn began,” the most intuitive approach is to note the array length at that moment.
But once a ring buffer is full, it drops elements from the head — the index you noted “slides away” and points somewhere else.
The right approach is to remember a reference to the element itself, then use lastIndexOf to look up where it is now. If it has already been pushed out, the lookup returns -1 and the code degrades to “include every error” — a safe fallback: better to over-report than to miss something.
2.8 ask(): a convenience wrapper for one-shot calls
The end of the file also exports an ask() function, a one-shot wrapper around QueryEngine — create an instance, run one round, hand the file cache back to the caller:
export async function* ask({...}) {
const engine = new QueryEngine({
...,
readFileCache: cloneFileStateCache(getReadFileCache()), // ★ what gets passed in is a clone
})
try {
yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
} finally {
setReadFileCache(engine.getReadFileState()) // ★ hand it back no matter what
}
}
Two details:
- The file cache passed in is a clone. So this call’s changes to the cache do not affect the outside world right away — not until it is explicitly handed back at the end.
- The
finallyblock guarantees the hand-back. Even if an exception is thrown or the call is interrupted midway, the knowledge of “which files the model has read” is not lost. Losing it would cause the next turn to inject memory twice or misjudge file freshness.