@thincoder/core 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/advisor/loop.mjs +2 -2
- package/advisor/run.mjs +1 -1
- package/agent/family-tools.mjs +20 -10
- package/agent/setup-reminders.mjs +67 -11
- package/agent-tools/advisor.mjs +2 -2
- package/agent-tools/batch-lifecycle.mjs +245 -0
- package/agent-tools/batch-segment.mjs +16 -263
- package/agent-tools/batch-skeleton.mjs +94 -0
- package/agent-tools/batch.mjs +397 -0
- package/agent-tools/eng.mjs +4 -0
- package/agent-tools/parent-channel.mjs +17 -0
- package/agent-tools/plan.mjs +34 -0
- package/agent-tools.mjs +5 -1
- package/generate-title.mjs +6 -6
- package/i18n.mjs +4 -4
- package/ledger-cmd.mjs +30 -7
- package/ledger-db.mjs +22 -2
- package/ledger-executors.mjs +103 -0
- package/ledger-surface.mjs +15 -8
- package/ledger.mjs +15 -4
- package/manifest.mjs +172 -60
- package/package.json +18 -1
- package/prompts/advisor-design.md +1 -1
- package/prompts/advisor-round2.md +1 -1
- package/prompts/advisor-round3.md +1 -1
- package/prompts/common.md +1 -1
- package/prompts/persona-eng-coder.md +1 -1
- package/prompts/persona-eng-designer.md +1 -1
- package/prompts/persona-engineering.md +8 -5
- package/session-gc.mjs +118 -74
- package/session-lifecycle.mjs +18 -5
- package/session-slots-manifest.mjs +36 -3
- package/session-stale.mjs +247 -0
- package/tools/git.mjs +14 -9
- package/traces/trace-cleanup.mjs +109 -0
- package/traces/trace-store.mjs +32 -36
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-stale.mjs — sessions 存量治理(SESSION.md §6.17 · STARTUP-LATENCY 批,2026-09-21)。
|
|
3
|
+
*
|
|
4
|
+
* 职责 = 分组 / 三合取判据 / 回收(rename 进回收批)/ 清运(超期批)/ 有界逐扫。
|
|
5
|
+
* 全链异步(D-SE34):本档零同步扫描——目录与逐文件面一律 `node:fs/promises`
|
|
6
|
+
* (同步 fs 面只留单条目 `existsSync`——本档不使用;bounded 单文件面归既有档)。
|
|
7
|
+
*
|
|
8
|
+
* 可清组 ⟺ 三合取(D-SE35;组 = sessions 根下同一 40 位 cwd 哈希前缀的全文件集合):
|
|
9
|
+
* ③ 安全窗:组内最新 mtime < now − STALE_SAFETY_WINDOW_MS(7 天——异常现场窗口);
|
|
10
|
+
* ① 无活属主:manifest `slotSessions` 全量经入口一次探测束 + `ownerState` 三态——
|
|
11
|
+
* 活 / 未知(探测失败 / 缺行)⇒ 保留;无 manifest ⇒ 无认领面 ⇒ 该条自动满足;
|
|
12
|
+
* ② 内容面不可达或无内容:**T1** = 组内可读数据文件 `cwd` 全部不存在于磁盘(至少读到一份);
|
|
13
|
+
* **T2** = 组内无任何数据文件。数据文件不可读 / 无 `cwd` 字段 ⇒ T1 不成立 ⇒ 保留(fail-safe)。
|
|
14
|
+
* 求值序 = ③ → ① → ②(任一步不通过 ⇒ 保留并短路);选取顺序 = 组最新 mtime 升序(最旧优先)。
|
|
15
|
+
* 预算(D-SE40)= **过 ③ 进 ① 即耗 1**(含 ① 面 manifest 读)——每 pass 总评估 ≤ `STALE_SWEEP_LIMIT`,
|
|
16
|
+
* 达上限即停、余量下一 pass 继续;③ 短路组零预算(未读盘),① / ② 短路组已耗 1(不退);
|
|
17
|
+
* 显式面 limit = Infinity(全量)。
|
|
18
|
+
* 90 天冷 cwd 判据**不在本档**(cwd 存活组唯一出口——保持显式命令面;实现住 session-gc.mjs)。
|
|
19
|
+
*
|
|
20
|
+
* 回收(D-SE36):判据窗 7 天 + 回收保留 7 天 ⇒ 不可逆删除最早 = 最后写入 + 14 天;
|
|
21
|
+
* 回收根 = 当次 sessions 根 `dir` 派生的同级 `sessions-trash/<批次时间戳>/`(sessions 根外——
|
|
22
|
+
* 不参与扫描 / 不入发现面;`dir` 注入缝因此覆盖回收批);逐文件 rename(同卷元数据操作)——
|
|
23
|
+
* 单文件失败 / 回收根不可写 = 跳过并计数,**不 unlink 兜底**(零误删;部分移动态安全)。
|
|
24
|
+
*/
|
|
25
|
+
import { readdir, readFile, stat, rename, mkdir, rm } from "node:fs/promises"
|
|
26
|
+
import { join, dirname } from "node:path"
|
|
27
|
+
import { ownerPid } from "./session-slots-manifest.mjs"
|
|
28
|
+
import { probeOwnersAsync, ownerState } from "./process-probe.mjs"
|
|
29
|
+
|
|
30
|
+
export const STALE_SAFETY_WINDOW_MS = 7 * 24 * 3600 * 1000
|
|
31
|
+
export const STALE_SWEEP_LIMIT = 500
|
|
32
|
+
export const TRASH_RETENTION_MS = 7 * 24 * 3600 * 1000
|
|
33
|
+
export const TRASH_DIR_NAME = "sessions-trash"
|
|
34
|
+
|
|
35
|
+
/** 测试注入缝(`_traceHooks` 同惯例):rename / mkdir / rm / stat / readdir 计数与失败注入。
|
|
36
|
+
* 生产永远走真实实现(本对象只在测试内 `Object.assign` + 还原)。 */
|
|
37
|
+
export const _staleHooks = {
|
|
38
|
+
readdir: (dir) => readdir(dir),
|
|
39
|
+
readFile: (p) => readFile(p, "utf8"),
|
|
40
|
+
stat: (p) => stat(p),
|
|
41
|
+
rename: (from, to) => rename(from, to),
|
|
42
|
+
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
|
43
|
+
rm: (dir) => rm(dir, { recursive: true, force: true }),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const GROUP_RE = /^([0-9a-f]{40})\.json(?:\.|$)/
|
|
47
|
+
const DATA_SUFFIX_RE = /^(?:\.\d+)?$/
|
|
48
|
+
const STAT_CHUNK = 128
|
|
49
|
+
|
|
50
|
+
/** 回收根(D-SE36)= 当次 sessions 根的同级 `sessions-trash`。 */
|
|
51
|
+
export function trashRootFor(dir) { return join(dirname(dir), TRASH_DIR_NAME) }
|
|
52
|
+
|
|
53
|
+
/** 分组(纯函数):entries(名串或 {name, mtimeMs})→ Map<hash, {hash, prefix, files,
|
|
54
|
+
* dataFiles, newestMtimeMs}>。只认 40 位哈希前缀族(非本族名一律不碰);数据文件 =
|
|
55
|
+
* 裸 `{hash}.json` 与 `{hash}.json.N`(记录存储 sidecar `.N.d` 与残留随组处理,不计入)。 */
|
|
56
|
+
export function groupSessionEntries(entries) {
|
|
57
|
+
const groups = new Map()
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
const name = typeof e === "string" ? e : e?.name
|
|
60
|
+
if (typeof name !== "string") continue
|
|
61
|
+
const m = GROUP_RE.exec(name)
|
|
62
|
+
if (!m) continue
|
|
63
|
+
const hash = m[1]
|
|
64
|
+
const prefix = `${hash}.json`
|
|
65
|
+
let g = groups.get(hash)
|
|
66
|
+
if (!g) { g = { hash, prefix, files: [], dataFiles: [], newestMtimeMs: 0 }; groups.set(hash, g) }
|
|
67
|
+
g.files.push(name)
|
|
68
|
+
if (DATA_SUFFIX_RE.test(name.slice(prefix.length))) g.dataFiles.push(name)
|
|
69
|
+
if (typeof e !== "string") g.newestMtimeMs = Math.max(g.newestMtimeMs, Number(e?.mtimeMs) || 0)
|
|
70
|
+
}
|
|
71
|
+
return groups
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 三合取判定(**纯函数**——零 IO;入参 = 已取证事实):
|
|
75
|
+
* facts = { now, newestMtimeMs, ownerStates, dataFiles: [{readable, cwdExists}] }
|
|
76
|
+
* → { cleanable, reason }(reason 面:window / live-owner / unknown-owner / no-content /
|
|
77
|
+
* cwd-gone / cwd-exists / unreadable)。 */
|
|
78
|
+
export function judgeStaleGroup(facts) {
|
|
79
|
+
const { now, newestMtimeMs = 0, ownerStates = [], dataFiles = [] } = facts
|
|
80
|
+
if (!(newestMtimeMs < now - STALE_SAFETY_WINDOW_MS)) return { cleanable: false, reason: "window" }
|
|
81
|
+
if (ownerStates.includes("alive")) return { cleanable: false, reason: "live-owner" }
|
|
82
|
+
if (ownerStates.includes("unknown")) return { cleanable: false, reason: "unknown-owner" }
|
|
83
|
+
if (!dataFiles.length) return { cleanable: true, reason: "no-content" } // T2
|
|
84
|
+
for (const f of dataFiles) { // T1:可读数据文件 cwd 全不在盘(至少一份;不可读 ⇒ fail-safe 保留)
|
|
85
|
+
if (!f.readable || f.cwdExists == null) return { cleanable: false, reason: "unreadable" }
|
|
86
|
+
if (f.cwdExists) return { cleanable: false, reason: "cwd-exists" }
|
|
87
|
+
}
|
|
88
|
+
return { cleanable: true, reason: "cwd-gone" }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 目录快照 → 组集合:本族名逐条目 `stat` 异步(分块——非阻塞、不进启动关键路径)。
|
|
92
|
+
* names 缺省 = 自行 readdir(dir 注入缝;pass 编排可传共享快照——readdir 恰一次)。
|
|
93
|
+
* `hashes` 在场 ⇒ **按前缀预筛后再 stat**(单组重校验面:逐组调用不随存量放大——
|
|
94
|
+
* `--confirm <hash>` / 端侧命令面的逐组重校验成本 = O(该组文件数))。 */
|
|
95
|
+
async function snapshotGroups(dir, names, hashes = null) {
|
|
96
|
+
if (!names) {
|
|
97
|
+
try { names = await _staleHooks.readdir(dir) } catch { return new Map() }
|
|
98
|
+
}
|
|
99
|
+
const want = hashes ? hashes.map((h) => `${h}.json`) : null
|
|
100
|
+
const hit = names.map((n) => (typeof n === "string" ? n : n?.name))
|
|
101
|
+
.filter((n) => typeof n === "string" && GROUP_RE.test(n) && (!want || want.some((p) => n === p || n.startsWith(p + "."))))
|
|
102
|
+
const facts = []
|
|
103
|
+
for (let i = 0; i < hit.length; i += STAT_CHUNK) {
|
|
104
|
+
const chunk = await Promise.all(hit.slice(i, i + STAT_CHUNK).map(async (name) => {
|
|
105
|
+
try { return { name, mtimeMs: (await _staleHooks.stat(join(dir, name))).mtimeMs } } catch { return null }
|
|
106
|
+
}))
|
|
107
|
+
for (const f of chunk) if (f) facts.push(f)
|
|
108
|
+
}
|
|
109
|
+
return groupSessionEntries(facts)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** ① 面入参:manifest `slotSessions` 属主 pid(manifest 缺失 / 损坏 ⇒ 空 ⇒ 该条自动满足)。 */
|
|
113
|
+
async function manifestOwners(dir, prefix) {
|
|
114
|
+
let m
|
|
115
|
+
try { m = JSON.parse(await _staleHooks.readFile(join(dir, `${prefix}.manifest`))) } catch { return [] }
|
|
116
|
+
const pids = []
|
|
117
|
+
for (const owner of Object.values(m?.slotSessions ?? {})) {
|
|
118
|
+
const pid = typeof owner === "string" && owner ? ownerPid(owner) : 0
|
|
119
|
+
if (pid) pids.push(pid)
|
|
120
|
+
}
|
|
121
|
+
return pids
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** cwd 是否在盘(异步单条目);非 ENOENT(权限 / 网络错)⇒ 不可判 ⇒ 视为存在(保守向)。 */
|
|
125
|
+
async function pathExists(p) {
|
|
126
|
+
try { await _staleHooks.stat(p); return true } catch (e) { return e?.code !== "ENOENT" }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** ② 面取证:逐数据文件(可读 ∧ cwd 字段在盘)。不可读 / 无 `cwd` ⇒ readable:false(fail-safe)。 */
|
|
130
|
+
async function dataFileFacts(dir, group) {
|
|
131
|
+
const out = []
|
|
132
|
+
for (const name of group.dataFiles) {
|
|
133
|
+
let data = null
|
|
134
|
+
try { data = JSON.parse(await _staleHooks.readFile(join(dir, name))) } catch { data = null }
|
|
135
|
+
const cwd = typeof data?.cwd === "string" && data.cwd ? data.cwd : null
|
|
136
|
+
out.push(cwd ? { readable: true, cwdExists: await pathExists(cwd) } : { readable: false, cwdExists: null })
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 候选列举(显式面 / 自动面共用):③(廉价——短路组零预算)→ ①(**过 ③ 进 ① 即耗 1 预算**——
|
|
143
|
+
* 该组 manifest 读与(若过 ① 的)② 内容读均在此预算内)→ ②(内容判据)。
|
|
144
|
+
* 预算闸 = 过 ③ 进 ① 的组数 ≤ `limit`(达上限即停——升序余量下一 pass 继续;显式面传 Infinity)。
|
|
145
|
+
* 返回 { candidates, evaluated, groups }——`evaluated` = 过 ③ 进 ① 的组数(= 预算消耗组数,
|
|
146
|
+
* 含 ① 面 manifest 读——D-SE40)。
|
|
147
|
+
* `hashes` 在场 ⇒ 只判这些 hash(单组重校验面——`deleteStaleCwd` 用)。
|
|
148
|
+
*/
|
|
149
|
+
export async function listStaleCwds({ dir, entries = null, now = Date.now(), probeFn = probeOwnersAsync, limit = STALE_SWEEP_LIMIT, hashes = null } = {}) {
|
|
150
|
+
const groups = await snapshotGroups(dir, entries, hashes)
|
|
151
|
+
const all = [...groups.values()]
|
|
152
|
+
const wanted = hashes ? all.filter((g) => hashes.includes(g.hash)) : all
|
|
153
|
+
const ordered = wanted.sort((a, b) => a.newestMtimeMs - b.newestMtimeMs || (a.hash < b.hash ? -1 : 1))
|
|
154
|
+
const ownersByHash = new Map()
|
|
155
|
+
const allPids = new Set()
|
|
156
|
+
let evaluated = 0
|
|
157
|
+
for (const g of ordered) {
|
|
158
|
+
if (!(g.newestMtimeMs < now - STALE_SAFETY_WINDOW_MS)) continue // ③ 短路(零预算——未读盘)
|
|
159
|
+
if (evaluated >= limit) break // 预算闸(过 ③ 进 ① 即耗——达上限即停;余量下一 pass 继续)
|
|
160
|
+
evaluated++ // 该组 ① manifest 读 +(若过 ① 的)② 内容读均在此预算内(D-SE40)
|
|
161
|
+
const pids = await manifestOwners(dir, g.prefix)
|
|
162
|
+
ownersByHash.set(g.hash, pids)
|
|
163
|
+
for (const pid of pids) allPids.add(pid)
|
|
164
|
+
}
|
|
165
|
+
const bundle = allPids.size ? await probeFn([...allPids]) : { aliveSet: null, cmds: null }
|
|
166
|
+
const candidates = []
|
|
167
|
+
for (const g of ordered) {
|
|
168
|
+
const pids = ownersByHash.get(g.hash)
|
|
169
|
+
if (!pids) continue // ③ 短路 / 预算外(未进 ①)
|
|
170
|
+
const states = pids.map((pid) => ownerState(pid, bundle))
|
|
171
|
+
if (states.some((s) => s !== "dead")) continue // ① 短路(活 / 未知 ⇒ 保留——预算已耗,不退)
|
|
172
|
+
const verdict = judgeStaleGroup({ now, newestMtimeMs: g.newestMtimeMs, ownerStates: states, dataFiles: await dataFileFacts(dir, g) })
|
|
173
|
+
if (verdict.cleanable) {
|
|
174
|
+
candidates.push({ hash: g.hash, prefix: g.prefix, reason: verdict.reason, dataFiles: g.dataFiles.length, newestMtime: g.newestMtimeMs, files: g.files })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { candidates, evaluated, groups: ordered.length }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 回收序(§6.17 落地顺序):残留 / 端标记 → manifest → 数据文件。 */
|
|
181
|
+
function recycleRank(name, prefix) {
|
|
182
|
+
const rest = name.slice(prefix.length)
|
|
183
|
+
if (rest === ".manifest") return 1
|
|
184
|
+
if (rest === "" || /^\.\d+$/.test(rest) || /^\.\d+\.d$/.test(rest)) return 2
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 逐组回收(组粒度——非事务):逐文件 rename 进回收批。单文件失败 / 回收根不可写 = 跳过并计数,
|
|
189
|
+
* 不 unlink 兜底(原文件零删除);返回 { moved, skipped, batch }(零移动 ⇒ batch:null)。 */
|
|
190
|
+
export async function recycleGroup(group, { dir, now = Date.now() } = {}) {
|
|
191
|
+
const batch = join(trashRootFor(dir), String(now))
|
|
192
|
+
const out = { moved: [], skipped: [], batch: null }
|
|
193
|
+
const ordered = [...group.files].sort((a, b) => recycleRank(a, group.prefix) - recycleRank(b, group.prefix))
|
|
194
|
+
for (const name of ordered) {
|
|
195
|
+
try {
|
|
196
|
+
if (!out.batch) { await _staleHooks.mkdir(batch); out.batch = batch }
|
|
197
|
+
await _staleHooks.rename(join(dir, name), join(batch, name))
|
|
198
|
+
out.moved.push(name)
|
|
199
|
+
} catch { out.skipped.push(name) }
|
|
200
|
+
}
|
|
201
|
+
if (!out.moved.length) out.batch = null
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 单组回收(显式命令面 / 端侧命令面):重校验(TOCTOU——单组重判三合取)→ 回收。
|
|
206
|
+
* `entries` = 目录名快照(可选——命令面循环可传同一快照免逐组全目录 readdir;
|
|
207
|
+
* mtime 事实同自动面 pass 模型,**① 面 manifest 读与探测束仍逐组新鲜**——变活组由 ① 拦截)。 */
|
|
208
|
+
export async function deleteStaleCwd(hash, { dir, now = Date.now(), probeFn = probeOwnersAsync, entries = null } = {}) {
|
|
209
|
+
const { candidates } = await listStaleCwds({ dir, now, probeFn, limit: Infinity, hashes: [hash], entries })
|
|
210
|
+
const target = candidates.find((c) => c.hash === hash)
|
|
211
|
+
if (!target) return { ok: false, reason: "not-cold", deleted: [] } // 期间变活 / 出窗 ⇒ 拒绝(零删除)
|
|
212
|
+
const r = await recycleGroup(target, { dir, now })
|
|
213
|
+
if (!r.moved.length) return { ok: false, reason: "recycle-failed", deleted: [], skipped: r.skipped }
|
|
214
|
+
return { ok: true, reason: target.reason, deleted: r.moved, skipped: r.skipped, batch: r.batch }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 清运(D-SE36):超期回收批整删(批名 = 批次时间戳,13 位 epoch ms ⇒ 到期可比)。失败静默跳过
|
|
218
|
+
* (不误删在期批);后续 pass 重试。非批次名(外来目录)一律不碰。 */
|
|
219
|
+
export async function purgeTrash({ dir, now = Date.now(), retentionMs = TRASH_RETENTION_MS } = {}) {
|
|
220
|
+
const root = trashRootFor(dir)
|
|
221
|
+
const out = { purged: [], failed: [] }
|
|
222
|
+
let batches
|
|
223
|
+
try { batches = await _staleHooks.readdir(root) } catch { return out } // 无回收根 ⇒ 无事可做
|
|
224
|
+
for (const batch of batches) {
|
|
225
|
+
if (!/^\d{13}$/.test(batch)) continue // 批名形态锁(本档写入口 = String(Date.now()))——外来目录不碰
|
|
226
|
+
const ts = Number(batch)
|
|
227
|
+
if (!(ts + retentionMs <= now)) continue
|
|
228
|
+
try { await _staleHooks.rm(join(root, batch)); out.purged.push(batch) } catch { out.failed.push(batch) }
|
|
229
|
+
}
|
|
230
|
+
return out
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 有界清立面 pass(**自动面**——绑启动**窗外延迟拍**(`scheduleSessionGC`),每进程一次):
|
|
234
|
+
* 候选(**过 ③ 进 ① ≤ `limit`**——含 ① 面 manifest 读)→ 逐组回收 → 超期批清运。
|
|
235
|
+
* 全链异步;失败由调用面静默(本函数不抛——逐文件失败已计数)。 */
|
|
236
|
+
export async function sweepStale({ dir, entries = null, now = Date.now(), probeFn = probeOwnersAsync, limit = STALE_SWEEP_LIMIT } = {}) {
|
|
237
|
+
const { candidates, evaluated, groups } = await listStaleCwds({ dir, entries, now, probeFn, limit })
|
|
238
|
+
let moved = 0
|
|
239
|
+
let skipped = 0
|
|
240
|
+
for (const c of candidates) {
|
|
241
|
+
const r = await recycleGroup(c, { dir, now })
|
|
242
|
+
moved += r.moved.length
|
|
243
|
+
skipped += r.skipped.length
|
|
244
|
+
}
|
|
245
|
+
const purged = await purgeTrash({ dir, now })
|
|
246
|
+
return { groups, evaluated, candidates: candidates.length, moved, skipped, purged: purged.purged.length, purgeFailed: purged.failed.length }
|
|
247
|
+
}
|
package/tools/git.mjs
CHANGED
|
@@ -42,10 +42,14 @@ function resolveBaseDir(cwd, workdir) {
|
|
|
42
42
|
* 二者仍可经显式 `workdir` 指落点。 */
|
|
43
43
|
const NO_REPO_DISCOVERY = new Set(["init", "clone"])
|
|
44
44
|
|
|
45
|
-
/** 歧义(多态)消息:候选全列(绝对路径、按名排序)+ 指引显式 `workdir`——不猜(§6.13 解析序行 5
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
/** 歧义(多态)消息:候选全列(绝对路径、按名排序)+ 指引显式 `workdir`——不猜(§6.13 解析序行 5);
|
|
46
|
+
* **两档变体**(#188——按 `matched`):带档级 ⇒ 「.git ∧ PROJECT-MANIFEST.json」;裸仓级 ⇒ 「.git」
|
|
47
|
+
* 且注明 none carries …(不书带档句——§6.13 A22)。 */
|
|
48
|
+
function ambiguousRepoMessage(candidates, cwd, matched) {
|
|
49
|
+
const head = matched === "git"
|
|
50
|
+
? `Ambiguous git repo at ${cwd}: ${candidates.length} subdirectories carry .git (none carries ${MANIFEST_REL})`
|
|
51
|
+
: `Ambiguous git repo at ${cwd}: ${candidates.length} subdirectories carry .git and ${MANIFEST_REL}`
|
|
52
|
+
return `${head} — pass workdir to run git in the intended one (no silent pick):\n` + candidates.map((c) => `- ${c}`).join("\n")
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
// ─── 审批门注入缝(#59——「只读判定 / 审批门按端注入」,形态参 §2.13.5 注入缝)────────
|
|
@@ -108,14 +112,15 @@ export const gitTool = {
|
|
|
108
112
|
// workdir: run git in a subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
|
|
109
113
|
// every action + snapshotBefore + checkpoint resolves against the workdir.
|
|
110
114
|
if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
|
|
111
|
-
// 仓发现(§6.13 · #62):缺省路径(无 workdir)⇒ 单源 = `discoverRepos
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
+
// 仓发现(§6.13 · #62):缺省路径(无 workdir)⇒ 单源 = `discoverRepos`(仓梯——#188 含裸仓级)
|
|
116
|
+
// ——工作区根(非仓)⇒ 唯一带档子仓 / 零档时唯一裸仓:重定向 `ctx.cwd` + 结果首行注记;零 ⇒ 原值
|
|
117
|
+
// 落 §6.12 fail-closed(零行为变);多 ⇒ throw(列该级候选 + 指 workdir,不猜——消息按 `matched`
|
|
118
|
+
// 两档变体)。判据 = workdir 真值在场(与上行同源——空串与缺省同判);`init` / `clone` 例外
|
|
119
|
+
// 不做发现。self / none 态 ctx 对象引用透传(不 clone)。
|
|
115
120
|
let repoNote = ""
|
|
116
121
|
if (!args.workdir && !NO_REPO_DISCOVERY.has(args.action)) {
|
|
117
122
|
const discovered = discoverRepos(ctx.cwd)
|
|
118
|
-
if (discovered.kind === "ambiguous") throw new Error(ambiguousRepoMessage(discovered.candidates, ctx.cwd))
|
|
123
|
+
if (discovered.kind === "ambiguous") throw new Error(ambiguousRepoMessage(discovered.candidates, ctx.cwd, discovered.matched))
|
|
119
124
|
if (discovered.kind === "unique") {
|
|
120
125
|
ctx = { ...ctx, cwd: discovered.root }
|
|
121
126
|
repoNote = `(repo: ${discovered.root})`
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trace-cleanup.mjs — 轨迹清理执行面(TRACES.md §6.4 · STARTUP-LATENCY 批,2026-09-21)。
|
|
3
|
+
*
|
|
4
|
+
* 判据 = **目录级三段梯**(D-TR11):设 `D` = 日目录(`YYYY-MM-DD`——本地时区,与
|
|
5
|
+
* `localDateStr` 同口径)、`retention` = `traces.retentionHours`:
|
|
6
|
+
* ① `dayEnd(D) + retention ≤ now` **且目录内全部条目为 `.jsonl`** ⇒ **整目录删**
|
|
7
|
+
* (递归 rm,零逐文件 stat);
|
|
8
|
+
* ② `dayStart(D) + retention > now` ⇒ **整目录跳过**(正常写入下目录内无可过期文件);
|
|
9
|
+
* ③ 其余(含非 `.jsonl` 条目的目录;非日期名目录——日期解析 NaN ⇒ ①② 两条件恒假 ⇒ 自然落 ③)
|
|
10
|
+
* ⇒ 逐文件 `stat` 判定 + unlink(现行语义;非 `.jsonl` 一律不碰)。
|
|
11
|
+
* 空日目录在任何分支后按现法移除。
|
|
12
|
+
*
|
|
13
|
+
* 计数口径(D-TR11):整删支的 `removed` = 该批内 `.jsonl` 条目数(与 ③ 支逐文件计入同口径;
|
|
14
|
+
* 非 `.jsonl` 不计数)——既有断言面保持。
|
|
15
|
+
* 语义 delta(登记):整删不逐文件核 mtime(人为回拨 mtime 的文件随目录清理——正常写入
|
|
16
|
+
* mtime ∈ 目录日 ⇒ 无差异);② 支跳过时人为前拨(超期)文件暂留(宽容向)——保留期主粒度
|
|
17
|
+
* 改为**目录日**(与 §6.1 的 24h 保留期并读)。
|
|
18
|
+
*
|
|
19
|
+
* 本体**无状态**(手动面 / 每写节流面直调——启动面经 `scheduleTraceCleanup`);每写节流住
|
|
20
|
+
* `trace-store.mjs`(`maybePruneTraces`——D-TR12:10 分钟窗 + 在飞合并)。启动面 = **启动窗外
|
|
21
|
+
* 延迟拍**(D-TR13):`scheduleTraceCleanup` + `TRACE_CLEANUP_DELAY_MS`(3s)住本档、由 CLI
|
|
22
|
+
* 直引(白名单 / 闸位住调用面);`_setTraceCleanupDelayForTest` = 延迟测试缝。
|
|
23
|
+
* `_cleanupHooks` = 测试注入缝(`_traceHooks` 同惯例——stat 计数 / 失败注入;生产永远走真实实现)。
|
|
24
|
+
*/
|
|
25
|
+
import { readdir, stat, unlink, rmdir, rm } from "node:fs/promises"
|
|
26
|
+
import { join } from "node:path"
|
|
27
|
+
|
|
28
|
+
export const _cleanupHooks = {
|
|
29
|
+
readdir: (dir) => readdir(dir),
|
|
30
|
+
stat: (p) => stat(p),
|
|
31
|
+
unlink: (p) => unlink(p),
|
|
32
|
+
rmdir: (d) => rmdir(d),
|
|
33
|
+
rm: (d) => rm(d, { recursive: true, force: true }),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
37
|
+
|
|
38
|
+
/** 日目录边界(本地时区——`localDateStr` 同口径);非日期名 / 非法日(如 2026-02-31)
|
|
39
|
+
* ⇒ null(⇒ ①② 两条件恒假 ⇒ 落 ③ 逐文件支)。 */
|
|
40
|
+
export function dayBounds(name) {
|
|
41
|
+
const m = DAY_RE.exec(name)
|
|
42
|
+
if (!m) return null
|
|
43
|
+
const y = Number(m[1])
|
|
44
|
+
const mo = Number(m[2])
|
|
45
|
+
const d = Number(m[3])
|
|
46
|
+
const start = new Date(y, mo - 1, d).getTime()
|
|
47
|
+
if (Number.isNaN(start) || new Date(start).getDate() !== d) return null // 2026-02-31 归一为 03-03 ⇒ 非日期名
|
|
48
|
+
return { start, end: new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* D-TR10 保留期语义 + D-TR11 目录级判据:删除 traces 根下 mtime 超过保留期的轨迹文件
|
|
53
|
+
* (保留期 = config.traces.retentionHours,默认 24h);删空的日期目录(YYYY-MM-DD)。
|
|
54
|
+
* 目录里非 .jsonl 文件不碰。返回删除文件数(整删支 = 批内 `.jsonl` 条目数)。
|
|
55
|
+
*/
|
|
56
|
+
export async function cleanupTraces({ dir, retentionHours = 24 } = {}) {
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
const cutoff = now - retentionHours * 3_600_000
|
|
59
|
+
const window = retentionHours * 3_600_000
|
|
60
|
+
let days
|
|
61
|
+
try { days = await _cleanupHooks.readdir(dir) } catch { return 0 } // 目录不存在/不可读 → 无事可做
|
|
62
|
+
let removed = 0
|
|
63
|
+
for (const day of days) {
|
|
64
|
+
const dayDir = join(dir, day)
|
|
65
|
+
let names
|
|
66
|
+
try { names = await _cleanupHooks.readdir(dayDir) } catch { continue } // 非目录 / 不可读 → 跳过
|
|
67
|
+
const bounds = dayBounds(day)
|
|
68
|
+
if (bounds && bounds.end + window <= now && names.every((n) => n.endsWith(".jsonl"))) {
|
|
69
|
+
// ① 整目录删(纯 .jsonl 且全目录过期——零逐文件 stat;批内条目全计入)
|
|
70
|
+
try { await _cleanupHooks.rm(dayDir); removed += names.length } catch { /* 占用/竞态——跳过 */ }
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (bounds && bounds.start + window > now) {
|
|
74
|
+
// ② 整目录跳过(未到期——空日目录仍按现法移除)
|
|
75
|
+
try { if (!names.length) await _cleanupHooks.rmdir(dayDir) } catch { /* 跳过 */ }
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
// ③ 逐文件(现行语义——非 .jsonl 一律不碰)
|
|
79
|
+
for (const n of names) {
|
|
80
|
+
if (!n.endsWith(".jsonl")) continue
|
|
81
|
+
try {
|
|
82
|
+
if ((await _cleanupHooks.stat(join(dayDir, n))).mtimeMs < cutoff) { await _cleanupHooks.unlink(join(dayDir, n)); removed++ }
|
|
83
|
+
} catch { /* 单个文件失败不影响其余 */ }
|
|
84
|
+
}
|
|
85
|
+
try { if ((await _cleanupHooks.readdir(dayDir)).length === 0) await _cleanupHooks.rmdir(dayDir) } catch { /* 跳过 */ }
|
|
86
|
+
}
|
|
87
|
+
return removed
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── 启动窗外延迟拍(D-TR13 · 2026-09-21 收口前机制微修 2)────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** 延迟(自**调度点**起):与 D-SE39 的 `GC_PASS_DELAY_MS` 同值同形态——3s ⇒ 清理起点落于
|
|
93
|
+
* 启动窗(TTY 门 ≤2s)之外,启动链不因本清理的 fs 爆发与自身争同一事件循环。 */
|
|
94
|
+
export const TRACE_CLEANUP_DELAY_MS = 3000
|
|
95
|
+
let traceCleanupDelayMs = TRACE_CLEANUP_DELAY_MS
|
|
96
|
+
|
|
97
|
+
/** 测试缝(`_setSessionGcDelayForTest` 同款):用例置 0–短值即可点火(勿真等 3s);
|
|
98
|
+
* 还原 = `_setTraceCleanupDelayForTest(TRACE_CLEANUP_DELAY_MS)`。 */
|
|
99
|
+
export function _setTraceCleanupDelayForTest(ms) { traceCleanupDelayMs = ms }
|
|
100
|
+
|
|
101
|
+
/** 启动面调度器(CLI 会话型命令白名单闸内直引——`thincoder-cli/bin/thincoder.mjs`):
|
|
102
|
+
* `dir` / `retentionHours` 由**调度点**捕获(调用面取 `tracesRoot()`);延迟
|
|
103
|
+
* `TRACE_CLEANUP_DELAY_MS` 后点火。失败静默(`.catch` 在此——fire-and-forget 语义不变);
|
|
104
|
+
* **不 unref**(保后台排空现状——同 D-SE39)。**无去重闸**:白名单 / 闸位 / 每进程一次语义
|
|
105
|
+
* 住调用面(启动面恰一处调用);进程早退(显式 `process.exit`)未及拍 ⇒ 本次不执行
|
|
106
|
+
* (幂等——下次会话型命令照常清理)。 */
|
|
107
|
+
export function scheduleTraceCleanup({ dir, retentionHours = 24 } = {}) {
|
|
108
|
+
setTimeout(() => { cleanupTraces({ dir, retentionHours }).catch(() => { /* 失败静默——同上 */ }) }, traceCleanupDelayMs)
|
|
109
|
+
}
|
package/traces/trace-store.mjs
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* **单遍序列化**:脱敏内联进输出缓冲(不构造复制图 + 二次全量字符串)——字段名/次序
|
|
21
21
|
* 与 `JSON.stringify` 形态同构(T-TR1 等价断言)。
|
|
22
22
|
* - 容量 / 清理(§2.5 #116 / A21 已裁 · 按建议——取 VSC 侧):**完整落盘**(不截断、
|
|
23
|
-
* 不丢行、不丢记录);**清理 = 每写一次 prune**(写盘成功后顺带 `
|
|
24
|
-
* 与写盘同在 fire-and-forget
|
|
23
|
+
* 不丢行、不丢记录);**清理 = 每写一次 prune**(写盘成功后顺带 `maybePruneTraces`——
|
|
24
|
+
* 与写盘同在 fire-and-forget 异步体内)。2026-09-21(TRACES.md §6.4):判据改**目录级三段梯**
|
|
25
|
+
* + 每写 prune **节流**;清理实现外提 `trace-cleanup.mjs`(本档包装保既有 import 面)。
|
|
25
26
|
* - seq = 当日目录内最大已有 seq + 1(D-TR3——跨会话/进程重启不覆写既有旧轨迹——
|
|
26
27
|
* 18.6.1 评审 #3);**进程内 seqCache(§23.3.2——消除逐调用同步扫目录)**:首次
|
|
27
28
|
* readdirSync 后进程内递增预留,目录被清理(retention)后取下界重扫一次(防御)。
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
* 记录不输出 round 字段(全设计无 round 定义——删——不发明无来源字段)。
|
|
36
37
|
*/
|
|
37
38
|
import { readdirSync, existsSync } from "node:fs"
|
|
38
|
-
import { appendFile, mkdir
|
|
39
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
40
|
+
import { cleanupTraces as cleanupTracesImpl } from "./trace-cleanup.mjs"
|
|
39
41
|
import { join } from "node:path"
|
|
40
42
|
import { createHash } from "node:crypto"
|
|
41
43
|
import { configDir, loadConfig } from "../config.mjs"
|
|
@@ -87,17 +89,33 @@ export function tracesDirFor(dateStr) {
|
|
|
87
89
|
return join(tracesRoot(), dateStr)
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
// ─── 容量 / 清理策略(§2.5 #116 / A21 已裁 · 按建议——取 VSC
|
|
92
|
+
// ─── 容量 / 清理策略(§2.5 #116 / A21 已裁 · 按建议——取 VSC 侧)(§6.4 判据面)─────────
|
|
91
93
|
// 完整落盘:不截断、不丢行(无单消息/单记录额度,无在途丢弃);清理 = 每写一次 prune
|
|
92
|
-
//
|
|
94
|
+
// (写盘成功后顺带清理——与写盘同在 fire-and-forget 异步体内,永不阻塞 chat)。
|
|
93
95
|
|
|
94
|
-
// 进程内 seq 缓存(§23.3.2:dir →
|
|
95
|
-
// 目录被清理(retention)后取下界重扫一次(防御))。
|
|
96
|
+
// 进程内 seq 缓存(§23.3.2:dir → 已预留 max;首次读盘后递增预留,目录被清理后取下界重扫一次)。
|
|
96
97
|
const _seqCache = new Map()
|
|
98
|
+
// 每写 prune 节流(D-TR12 · §6.4):窗 = 10 分钟 + 在飞合并(同窗 3 连写 ⇒ 扫描 ≤1 次)。
|
|
99
|
+
export const PRUNE_THROTTLE_MS = 10 * 60_000
|
|
100
|
+
let _pruneAt = 0, _pruneInFlight = null
|
|
97
101
|
|
|
98
|
-
/** 测试缝:清进程内状态(seqCache
|
|
102
|
+
/** 测试缝:清进程内状态(seqCache + 节流窗/在飞——**跨用例残留**,用例须显式复位)。 */
|
|
99
103
|
export function _resetTraceStateForTest() {
|
|
100
104
|
_seqCache.clear()
|
|
105
|
+
_pruneAt = 0
|
|
106
|
+
_pruneInFlight = null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 每写 prune(`recordChatTrace` 写盘成功后调用):窗内 / 在飞 ⇒ 不发起新扫描。
|
|
110
|
+
* 返回 = 新扫描 promise(首次发起)/ `false`(窗内 / 在飞——不重复扫);`now` = 测试注入缝。 */
|
|
111
|
+
export function maybePruneTraces({ dir = tracesRoot(), retentionHours = 24, now = Date.now() } = {}) {
|
|
112
|
+
if (_pruneInFlight) return false // 在飞合并:同窗并发不重复发起扫描
|
|
113
|
+
if (now - _pruneAt < PRUNE_THROTTLE_MS) return false
|
|
114
|
+
_pruneAt = now
|
|
115
|
+
_pruneInFlight = cleanupTracesImpl({ dir, retentionHours })
|
|
116
|
+
.catch(() => 0) // 清理失败静默——不影响写盘(fire-and-forget 同纪律)
|
|
117
|
+
.finally(() => { _pruneInFlight = null })
|
|
118
|
+
return _pruneInFlight
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
function scanMaxSeq(dir) {
|
|
@@ -266,38 +284,16 @@ export function recordChatTrace(provider, opts = {}, result = null, error = null
|
|
|
266
284
|
await _traceHooks.append(join(dir, `${sessionKey}-${seq}.jsonl`), record + "\n")
|
|
267
285
|
let retentionHours = 24
|
|
268
286
|
try { retentionHours = loadConfig()?.traces?.retentionHours ?? 24 } catch { /* 默认 24h */ }
|
|
269
|
-
await
|
|
287
|
+
await maybePruneTraces({ dir: tracesRoot(), retentionHours }) // D-TR12:每写节流(窗内 ⇒ false 即返)
|
|
270
288
|
} catch {
|
|
271
289
|
// F-TR3:落盘/prune 失败静默降级——不抛错、不阻塞 chat() 返回
|
|
272
290
|
}
|
|
273
291
|
})()
|
|
274
292
|
}
|
|
275
293
|
|
|
276
|
-
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
* 后顺带调用——fire-and-forget 异步体内);CLI 启动点的启动清理保留(本端不动)。
|
|
282
|
-
* 返回删除文件数。
|
|
283
|
-
*/
|
|
284
|
-
export async function cleanupTraces({ dir = tracesRoot(), retentionHours = 24 } = {}) {
|
|
285
|
-
const cutoff = Date.now() - retentionHours * 3_600_000
|
|
286
|
-
let days
|
|
287
|
-
try { days = await readdir(dir) } catch { return 0 } // 目录不存在/不可读 → 无事可做
|
|
288
|
-
let removed = 0
|
|
289
|
-
for (const day of days) {
|
|
290
|
-
const dayDir = join(dir, day)
|
|
291
|
-
try { if (!(await stat(dayDir)).isDirectory()) continue } catch { continue }
|
|
292
|
-
let names
|
|
293
|
-
try { names = await readdir(dayDir) } catch { continue }
|
|
294
|
-
for (const n of names) {
|
|
295
|
-
if (!n.endsWith(".jsonl")) continue
|
|
296
|
-
try {
|
|
297
|
-
if ((await stat(join(dayDir, n))).mtimeMs < cutoff) { await unlink(join(dayDir, n)); removed++ }
|
|
298
|
-
} catch { /* 单个文件失败不影响其余 */ }
|
|
299
|
-
}
|
|
300
|
-
try { if ((await readdir(dayDir)).length === 0) await rmdir(dayDir) } catch {}
|
|
301
|
-
}
|
|
302
|
-
return removed
|
|
294
|
+
/** D-TR10 清理面(D-TR11 目录级三段梯)——实现外提 `traces/trace-cleanup.mjs`;此处包装保
|
|
295
|
+
* 既有 import 面与缺省 `dir`(`tracesRoot()` 单源——测试经 `THINCODER_TRACES_DIR` 隔离)。
|
|
296
|
+
* 调用点 = 每写 prune(经 `maybePruneTraces`)+ 启动清理(壳侧);返回删除文件数。 */
|
|
297
|
+
export function cleanupTraces({ dir = tracesRoot(), retentionHours = 24 } = {}) {
|
|
298
|
+
return cleanupTracesImpl({ dir, retentionHours })
|
|
303
299
|
}
|