审计证据、校验和与会话恢复 深度解析
HomeRail 不只保存模型最终文本:它分离记录 transcript、工具事件和会话快照,并在中断恢复时删除未完成工具调用,避免重复上下文污染。
导入链与契约
PromptRunner 启动时先在 sources/worker/src/prompt-runner.ts:167、sources/worker/src/prompt-runner.ts:168、sources/worker/src/prompt-runner.ts:169、sources/worker/src/prompt-runner.ts:174、sources/worker/src/prompt-runner.ts:175、sources/worker/src/prompt-runner.ts:177 创建 DAG 工具与两个审计 writer;sources/worker/src/prompt-runner.ts:182、sources/worker/src/prompt-runner.ts:183、sources/worker/src/prompt-runner.ts:185、sources/worker/src/prompt-runner.ts:186、sources/worker/src/prompt-runner.ts:188、sources/worker/src/prompt-runner.ts:190 立即写入 prompt_start 和裁剪后的任务预览。审计失败被视为非致命,但 session transcript 仍有另一条 best-effort 写入路径,因此运行控制与证据采集不会互相锁死。
32export interface AuditWriters { 33 transcript: TranscriptWriter; 34 toolEvents: ToolEventWriter; 35} 36 37export function createAuditWriters( 38 runId: string, 39 baseDir?: string, 40): AuditWriters { 41 assertSafeToolEventRunId(runId); 42 return { 43 transcript: createTranscriptWriter(runId, baseDir), 44 toolEvents: createToolEventWriter(runId, baseDir), 45 }; 46}
核心实现,逐段拆
▸Transcript 追加 JSONL,关闭时生成 sidecar 校验和
Transcript writer 在 sources/worker/src/audit/transcript-writer.ts:19、sources/worker/src/audit/transcript-writer.ts:23、sources/worker/src/audit/transcript-writer.ts:24、sources/worker/src/audit/transcript-writer.ts:25、sources/worker/src/audit/transcript-writer.ts:26、sources/worker/src/audit/transcript-writer.ts:27 校验 runId、创建目录并以 append 模式打开文件;每条记录由 sources/worker/src/audit/transcript-writer.ts:29、sources/worker/src/audit/transcript-writer.ts:30、sources/worker/src/audit/transcript-writer.ts:31、sources/worker/src/audit/transcript-writer.ts:32、sources/worker/src/audit/transcript-writer.ts:39、sources/worker/src/audit/transcript-writer.ts:41 加上时间戳后写入,close 回调在 sources/worker/src/audit/transcript-writer.ts:42、sources/worker/src/audit/transcript-writer.ts:43、sources/worker/src/audit/transcript-writer.ts:45、sources/worker/src/audit/transcript-writer.ts:46 生成校验和 sidecar。这样长运行不必把全文留在内存,同时结束后可以检查文件是否被截断或篡改。
19export function createTranscriptWriter( 20 runId: string, 21 baseDir?: string, 22): TranscriptWriter { 23 const safeRunId = assertSafeRunId(runId); 24 const dir = baseDir ?? homerailPath("audit", "transcripts"); 25 mkdirSync(dir, { recursive: true }); 26 const filePath = join(dir, `${safeRunId}.jsonl`); 27 const stream: Writable = createWriteStream(filePath, { flags: "a" }); 28 29 return { 30 write(entry: Record<string, unknown>) { 31 const line = JSON.stringify({ ...entry, ts: Date.now() }) + "\n"; 32 stream.write(line); 33 }, 34 flush() { 35 return new Promise<void>((resolve, reject) => { 36 stream.write("", (err) => (err ? reject(err) : resolve())); 37 }); 38 }, 39 close() { 40 return new Promise<void>((resolve, reject) => { 41 stream.end(() => { 42 writeTranscriptChecksum(filePath); 43 resolve(); 44 }); 45 stream.on("error", reject); 46 }); 47 }, 48 };
▸Session 快照去除密钥,中断在工具调用中则剔除半成品消息
会话快照的数据契约在 sources/worker/src/session/session-store.ts:24、sources/worker/src/session/session-store.ts:25、sources/worker/src/session/session-store.ts:26、sources/worker/src/session/session-store.ts:27、sources/worker/src/session/session-store.ts:29、sources/worker/src/session/session-store.ts:33 保存 run、node、消息、工具调用状态和去密配置;写盘前 sources/worker/src/session/session-store.ts:79、sources/worker/src/session/session-store.ts:80、sources/worker/src/session/session-store.ts:81、sources/worker/src/session/session-store.ts:82、sources/worker/src/session/session-store.ts:83、sources/worker/src/session/session-store.ts:87 只保留 provider、model 与 workspace,不持久化 API key。恢复时 sources/worker/src/session/resume.ts:16、sources/worker/src/session/resume.ts:17、sources/worker/src/session/resume.ts:20、sources/worker/src/session/resume.ts:25、sources/worker/src/session/resume.ts:29、sources/worker/src/session/resume.ts:30 判断是否中断于工具调用,并按 pendingToolCallId 同时删除未完成的 tool_use 与可能的局部 tool_result。
16export function resumeSession(runId: string, baseDir?: string): ResumeResult | null { 17 const state = loadSession(runId, baseDir); 18 if (!state) return null; 19 20 const wasInterrupted = state.toolCallState.inFlight; 21 22 // If interrupted mid-tool, strip the in-flight tool_use from the 23 // conversation so the agent can re-execute cleanly. 24 let messages = state.messages; 25 if (wasInterrupted && state.toolCallState.pendingToolCallId) { 26 // Remove any tool_result that might have been partially written for 27 // the pending call, and keep everything up to but not including the 28 // pending assistant message. 29 const pendingId = state.toolCallState.pendingToolCallId; 30 messages = messages.filter( 31 (m) => m.tool_call_id !== pendingId && !m.tool_calls?.some((tc) => tc.id === pendingId), 32 ); 33 } 34 35 return { 36 context: { 37 ...state.agentConfig, 38 apiKey: "", 39 baseUrl: "", 40 }, 41 messages, 42 wasInterrupted, 43 }; 44}
关键流程
session.json→ crash → sanitize pending call →resume context
工具事件单独按 runId 分片。sources/worker/src/audit/tool-event-writer.ts:19、sources/worker/src/audit/tool-event-writer.ts:20、sources/worker/src/audit/tool-event-writer.ts:23、sources/worker/src/audit/tool-event-writer.ts:24、sources/worker/src/audit/tool-event-writer.ts:35、sources/worker/src/audit/tool-event-writer.ts:36 先阻断路径穿越并计算文件位置,sources/worker/src/audit/tool-event-writer.ts:43、sources/worker/src/audit/tool-event-writer.ts:44、sources/worker/src/audit/tool-event-writer.ts:45、sources/worker/src/audit/tool-event-writer.ts:47、sources/worker/src/audit/tool-event-writer.ts:50、sources/worker/src/audit/tool-event-writer.ts:52 再把事件补上 run_id 和时间戳后追加。读取逻辑在 sources/worker/src/audit/tool-event-writer.ts:69、sources/worker/src/audit/tool-event-writer.ts:71、sources/worker/src/audit/tool-event-writer.ts:72、sources/worker/src/audit/tool-event-writer.ts:74、sources/worker/src/audit/tool-event-writer.ts:75、sources/worker/src/audit/tool-event-writer.ts:77 同时兼容新版分片文件和旧版全局文件,并按时间排序。
▸完整性检查必须成为上层策略,而不是假设
sources/worker/src/audit/index.ts:63、sources/worker/src/audit/index.ts:67、sources/worker/src/audit/index.ts:68、sources/worker/src/audit/index.ts:71、sources/worker/src/audit/index.ts:74、sources/worker/src/audit/index.ts:77 检查 transcript 是否存在并计算当前 hash,sources/worker/src/audit/index.ts:79、sources/worker/src/audit/index.ts:80、sources/worker/src/audit/index.ts:81、sources/worker/src/audit/index.ts:84、sources/worker/src/audit/index.ts:85、sources/worker/src/audit/index.ts:89 再验证 sidecar 和 tool-events。返回值是 complete 加 missing 列表,而 PromptRunner 的审计写入本身是 best-effort;高合规部署应在工作流末端显式调用这一检查,并把缺证据当成失败或人工复核。
边界 / 失败模式
- runId 含斜杠、反斜杠或非白名单字符会被拒绝,防止审计路径逃逸。
- 审计 writer 创建失败不会阻断 Agent;因此业务成功与证据完整必须分别判定。
- 旧版 tool-events.jsonl 仍会被读取,并按 run_id 过滤后与新版分片合并。
- 工具调用中断时删除未完成调用,可能导致外部副作用已发生但对话中没有结果;工具本身仍需幂等键。