Files
claude-code/src/utils/autonomyPersistence.ts
Claude 7a6e65caf7 refactor: 简化/复用/防御 — 清理 PR #386 审计发现
简化 (S1, S2):
- src/cli/print.ts: 抽出 dispatchHeadlessCronCommand 本地 helper,把
  cron 三个入口(onFire / onFireTask agent / onFireTask 非-agent)共享的
  「dedup-claim → input-close-recheck → onSuccess」管线集中到一处,
  避免三个分支在「claim 与 dispatch 之间发生 inputClosed」的处理上漂移。
  enqueueAndRun 再抽出来,使两个非-agent 分支共用一个 onSuccess 回调。
  约 -55 行重复模板。
- src/utils/autonomyPersistence.ts: 新增 retainActiveFirst<T> 泛型
  helper —— active 记录无条件保留(不参与 cap),inactive 按 timestamp
  desc 填满剩余预算;统一 selectPersistedAutonomyRuns / Flows 的两阶段
  排序语义。
- src/utils/autonomyRuns.ts、autonomyFlows.ts: 改用 retainActiveFirst,
  删掉重复的内联两阶段排序逻辑。

复用 (R1, review #8):
- tests/mocks/file-system.ts: 新增 readTempFile / tempPathExists 两个
  Bun.file 包装,补齐 Node fs.readFileSync / existsSync 在测试里的
  Bun-only 等价物。
- src/utils/__tests__/autonomyRuns.test.ts: 把全部 Node fs/path 导入
  (existsSync, readFileSync, mkdir, writeFile, path.join/resolve)替换为
  tests/mocks/file-system 的共享 helper + node:path(带 node: 前缀)。
  不再有 6 处 mkdir + writeFile 模板,统一用 writeTempFile(自带 mkdir-p)。
  解决 review #8 (Major) 的 Bun-only 运行时契约违反。

防御 (D1, OOM 早期信号):
- src/services/compact/postCompactCleanup.ts: 在 void import().then() 末尾
  补 .catch(logError)。当前 attributionHooks 是 stub,但当真实现被恢复
  且 sweepFileContentCache 抛错时,这个 .catch 阻止它变成 unhandled
  rejection(函数返回值是 void,调用者无从观察异步失败)。
- src/utils/autonomyRuns.ts: 给 active runs 加 100 条软上限 + 一次性
  warn。selectPersistedAutonomyRuns 仍然永不淘汰 active 记录,但跨过
  阈值时 logError 一次,作为 finalize-leak 早期信号——避免 active 无限
  增长悄悄使 AUTONOMY_RUNS_MAX 失效。
2026-04-29 13:23:41 +00:00

83 lines
2.4 KiB
TypeScript

import { mkdir, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import { lock } from './lockfile.js'
const persistenceLocks = new Map<string, Promise<void>>()
/**
* Two-phase persistence retention. Active records (queued/running, etc.) are
* always kept — capping them risks evicting in-flight work; that responsibility
* lives in caller-side leak detection. Inactive (terminal) records are ranked
* by `getTimestamp` desc and capped to fill the remaining budget below `max`.
*
* Returned list is sorted by `getTimestamp` desc regardless of activity, so
* the persisted file is plain reverse-chronological order — listings/UI can
* consume it directly without re-sorting.
*/
export function retainActiveFirst<T>(
records: readonly T[],
isActive: (record: T) => boolean,
getTimestamp: (record: T) => number,
max: number,
): T[] {
const sortDesc = (left: T, right: T) =>
getTimestamp(right) - getTimestamp(left)
const active = records.filter(isActive).slice().sort(sortDesc)
const history = records
.filter(record => !isActive(record))
.slice()
.sort(sortDesc)
.slice(0, Math.max(0, max - active.length))
return [...active, ...history].sort(sortDesc)
}
export function getAutonomyPersistenceLockCountForTests(): number {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
'getAutonomyPersistenceLockCountForTests can only be called in tests',
)
}
return persistenceLocks.size
}
export async function withAutonomyPersistenceLock<T>(
rootDir: string,
fn: () => Promise<T>,
): Promise<T> {
const key = resolve(rootDir)
const lockPath = join(key, '.claude', 'autonomy', '.lock')
const previous = persistenceLocks.get(key) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>(resolve => {
release = resolve
})
const chained = previous.then(() => current)
persistenceLocks.set(key, chained)
await previous
try {
await mkdir(join(key, '.claude', 'autonomy'), { recursive: true })
await writeFile(lockPath, '', { flag: 'a' })
const unlock = await lock(lockPath, {
lockfilePath: `${lockPath}.lock`,
retries: {
retries: 10,
factor: 1.2,
minTimeout: 10,
maxTimeout: 100,
},
})
try {
return await fn()
} finally {
await unlock().catch(() => {})
}
} finally {
release()
if (persistenceLocks.get(key) === chained) {
persistenceLocks.delete(key)
}
}
}