@thincoder/core 0.9.1 → 0.9.3
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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/advisor/loop.mjs +2 -2
- package/advisor/run.mjs +1 -1
- package/agent/completion.mjs +3 -1
- package/agent/family-tools.mjs +24 -11
- package/agent/helpers.mjs +11 -2
- package/agent/run-stages.mjs +27 -5
- package/agent/setup-reminders.mjs +67 -11
- package/agent/setup.mjs +6 -0
- package/agent/write-gate.mjs +5 -5
- package/agent-tools/advisor-async.mjs +4 -4
- package/agent-tools/advisor.mjs +3 -3
- package/agent-tools/async-discard.mjs +1 -1
- package/agent-tools/audit-block.mjs +106 -0
- package/agent-tools/batch-lifecycle.mjs +301 -0
- package/agent-tools/batch-segment.mjs +16 -263
- package/agent-tools/batch-skeleton.mjs +156 -0
- package/agent-tools/batch.mjs +410 -0
- package/agent-tools/context.mjs +174 -0
- package/agent-tools/eng.mjs +4 -0
- package/agent-tools/goal.mjs +7 -0
- package/agent-tools/parent-channel.mjs +18 -1
- package/agent-tools/plan.mjs +39 -5
- package/agent-tools/read-history.mjs +122 -24
- package/agent-tools/settings.mjs +4 -2
- package/agent-tools/subagent-async.mjs +3 -3
- package/agent-tools/subagent-spawn.mjs +29 -101
- package/agent-tools/task.mjs +11 -0
- package/agent-tools.mjs +9 -2
- package/agent.mjs +10 -4
- package/config.mjs +1 -1
- package/context.mjs +66 -121
- package/fts-text.mjs +41 -0
- 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/memory/core.mjs +4 -18
- package/memory/schema.mjs +4 -11
- package/package.json +22 -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 +3 -3
- package/prompts/discipline-engineering.md +17 -2
- package/prompts/persona-eng-coder.md +1 -1
- package/prompts/persona-eng-designer.md +1 -1
- package/prompts/persona-engineering.md +9 -6
- package/session-gc.mjs +129 -74
- package/session-index-build.mjs +298 -0
- package/session-index-cmd.mjs +61 -0
- package/session-index-pass.mjs +95 -0
- package/session-index-query.mjs +102 -0
- package/session-index.mjs +285 -0
- package/session-lifecycle.mjs +18 -5
- package/session-slots-manifest.mjs +55 -3
- package/session-stale.mjs +247 -0
- package/token-window.mjs +188 -0
- package/tools/bash.mjs +4 -15
- package/tools/execute.mjs +5 -13
- package/tools/git-checkpoint.mjs +1 -1
- package/tools/git-ext.mjs +23 -20
- package/tools/git-run.mjs +141 -0
- package/tools/git.mjs +56 -45
- package/tools/index.mjs +3 -1
- package/tools/process-tree.mjs +20 -0
- package/tools/shared.mjs +8 -4
- package/traces/trace-cleanup.mjs +109 -0
- package/traces/trace-store.mjs +32 -36
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/git-run.mjs — git spawn 单点(TOOLS.md §6.14 · 批 GIT-NONINTERACTIVE · 台账 #207)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单点:git 的四交互族(编辑器 / 凭据 / GUI / pager)在**无 TTY** 下永久等待——
|
|
5
|
+
* `rebase --continue` 起编辑器即冻死整个会话(已发布 `0.12.64` 实报)。加固只许一处
|
|
6
|
+
* (逐调用点补 = 必漏):三个形适配器(`shared.mjs` `runGit` · `git.mjs` `runGitRaw` ·
|
|
7
|
+
* `git-ext.mjs` `runGitStrict`)体转**异步薄壳**委托本档,签名与返回形零变。
|
|
8
|
+
*
|
|
9
|
+
* 三件套:① `GIT_ENV` 加固集(**env 形**——实测 `GIT_EDITOR` env 优先于 `core.editor`,
|
|
10
|
+
* `-c` 形单独不足)② 两档超时(本地 120s / 网络 300s · 全量适用)③ 超时动作序
|
|
11
|
+
* (SIGTERM → 1.5s 树杀 → 1.5s kick——孙进程持管道时 `close` 永不触发)。
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process"
|
|
14
|
+
import { killProcessTree } from "./process-tree.mjs"
|
|
15
|
+
|
|
16
|
+
/** 加固集(逐字 = §6.14「加固集」):继承面在前,加固键一律置后覆盖——继承面不得反超。
|
|
17
|
+
* 编辑器族 4 键 · pager 族 2 键 · 凭据族 2 键(本批新增,bash 面亦无)· 终端族 1 键。 */
|
|
18
|
+
export const GIT_ENV = {
|
|
19
|
+
...process.env, // 继承面(PATH / HOME / 用户 proxy 等)
|
|
20
|
+
GIT_EDITOR: "true", // 编辑器族:提交信息编辑器(解析链最高优先键)
|
|
21
|
+
GIT_SEQUENCE_EDITOR: "true", // 编辑器族:rebase todo 列表编辑器
|
|
22
|
+
EDITOR: "true", // 编辑器族:兜底链 VISUAL / EDITOR
|
|
23
|
+
VISUAL: "true",
|
|
24
|
+
GIT_PAGER: "cat", // pager 族(bash 工具先例同值)
|
|
25
|
+
PAGER: "cat",
|
|
26
|
+
GIT_TERMINAL_PROMPT: "0", // 凭据族:禁终端提示
|
|
27
|
+
GIT_ASKPASS: "", // 凭据族:空串 ⇒ 不调 askpass 程序(同时封 core.askpass 升级路)
|
|
28
|
+
TERM: "dumb", // 终端族(bash 工具先例同值)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const GIT_TIMEOUT_MS = 120_000
|
|
32
|
+
export const GIT_NET_TIMEOUT_MS = 300_000
|
|
33
|
+
/** 网路面五动作(300s 档——合法耗时可远超本地;依据 = 需求档 §4.7 TTY-DRIVE N3 候选参照值)。 */
|
|
34
|
+
const GIT_NET_ACTIONS = new Set(["push", "fetch", "pull", "clone", "ls-remote"])
|
|
35
|
+
|
|
36
|
+
/** 测试态缝(模块级 · 缺省 null = 生产零行为变——先例 `manifest.mjs:40-41` · `session-gc.mjs:136`)。
|
|
37
|
+
* 用例 `finally` 复位;不经用户参数面(边界:不新增参数 / 用户选项)。 */
|
|
38
|
+
let gitTimeoutOverride = null
|
|
39
|
+
export function _setGitTimeoutForTest(ms) { gitTimeoutOverride = ms }
|
|
40
|
+
export function _resetGitTimeoutForTest() { gitTimeoutOverride = null }
|
|
41
|
+
|
|
42
|
+
/** 子命令取形:跳 `-c <k=v>` 对(适配器把 config 前置在 args 头)——首个其余 arg 即子命令。 */
|
|
43
|
+
function gitSubcommand(args) {
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
if (args[i] === "-c") { i++; continue }
|
|
46
|
+
return args[i]
|
|
47
|
+
}
|
|
48
|
+
return ""
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 超时文案单源(逐字 = §6.14「超时错误文案」):两帧嵌同一条——`runGit` / `runGitRaw` 抛错经
|
|
52
|
+
* `gitFailureMessage`;`runGitStrict` 的 `err` = 本条(调用方另加 `git <action> failed: ` 前缀)。 */
|
|
53
|
+
export function gitTimeoutNote(ms) {
|
|
54
|
+
return `timed out after ${ms / 1000}s (killed) — no interactive input is possible here (editor / credential / network); `
|
|
55
|
+
+ "process killed, tree best-effort — retry or use the bash tool; an interrupted write keeps git state (`git status`) "
|
|
56
|
+
+ "— git abort / continue, or checkpoint action=checkpoint checkpointAction=list"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** git spawn 单点。返回 Promise<string>(stdout 原文——trim / `\r` 归一 / 溢出分支等形由适配器保留)。
|
|
60
|
+
* 失败 / 超时 / 溢出 ⇒ reject;错误形 = `execFileSync` 同构(`.stdout` / `.stderr` / `.status` / `.message` /
|
|
61
|
+
* `.code`),超时另带 `.timedOut = true` / `.timeoutMs`(可辨性不靠 stderr 文本猜)。
|
|
62
|
+
* 缺省:timeout 按面取常量(网络五动作 300s / 其余 120s)· maxBuffer = Node `execFileSync` 缺省 1MB
|
|
63
|
+
* (`runGit` / `runGitRaw` 显式传 10MB——形保真清单 ②)。 */
|
|
64
|
+
export function spawnGit(cwd, args, { timeout, maxBuffer } = {}) {
|
|
65
|
+
const timeoutMs = timeout ?? gitTimeoutOverride ?? (GIT_NET_ACTIONS.has(gitSubcommand(args)) ? GIT_NET_TIMEOUT_MS : GIT_TIMEOUT_MS)
|
|
66
|
+
const cap = maxBuffer ?? 1024 * 1024
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn("git", args, {
|
|
69
|
+
cwd,
|
|
70
|
+
env: GIT_ENV,
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
detached: process.platform !== "win32", // POSIX:组首 ⇒ 树杀(-pid 组杀)可达孙进程(execute / bash 先例)
|
|
74
|
+
})
|
|
75
|
+
let stdout = "", stderr = "", mode = null, settled = false
|
|
76
|
+
let outBytes = 0, errBytes = 0 // maxBuffer 口径 = **byte**(`execFileSync` 同源)
|
|
77
|
+
let timer = null, killTimer = null, kickTimer = null
|
|
78
|
+
const finish = (fn) => {
|
|
79
|
+
if (settled) return
|
|
80
|
+
settled = true
|
|
81
|
+
clearTimeout(timer); clearTimeout(killTimer); clearTimeout(kickTimer)
|
|
82
|
+
fn()
|
|
83
|
+
}
|
|
84
|
+
const timeoutErr = () => Object.assign(new Error(gitTimeoutNote(timeoutMs)), {
|
|
85
|
+
code: "ETIMEDOUT", timedOut: true, timeoutMs, stdout, stderr, status: null,
|
|
86
|
+
})
|
|
87
|
+
const overflowErr = () => Object.assign(new Error(`git output exceeded maxBuffer (${cap} bytes)`), {
|
|
88
|
+
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", stdout, stderr, status: null,
|
|
89
|
+
})
|
|
90
|
+
const exitErr = (code, signal) => Object.assign(new Error(`Command failed: git ${args.join(" ")}${stderr ? `\n${stderr}` : ""}`), {
|
|
91
|
+
cmd: `git ${args.join(" ")}`, status: code, signal, killed: Boolean(signal), stdout, stderr,
|
|
92
|
+
})
|
|
93
|
+
const settleWith = (code, signal) => {
|
|
94
|
+
// 超时收尾:树杀必达(`close` 早退 ⇒ 1.5s 定时器已被 finish 清掉——**不持管道**的孙进程
|
|
95
|
+
// 会因此漏杀;树杀在头进程死后于 POSIX 仍可达全组)
|
|
96
|
+
if (mode === "timeout") { treeKill(); return reject(timeoutErr()) }
|
|
97
|
+
if (mode === "overflow") return reject(overflowErr())
|
|
98
|
+
if (code === 0 && !signal) return resolve(stdout)
|
|
99
|
+
return reject(exitErr(code, signal))
|
|
100
|
+
}
|
|
101
|
+
const treeKill = () => { try { killProcessTree(child) } catch { /* 尽力而为 */ } }
|
|
102
|
+
// 超时动作序(§6.14):① 直接子 → ② 逾 1.5s 树杀(`killProcessTree`,尽力而为)→ ③ 逾 1.5s 未 `close`
|
|
103
|
+
// 亦 settle(kick——孙进程持管道时 `close` 永不触发,`bash.mjs:200-217` 同款)。上界 = timeout + 3s。
|
|
104
|
+
// 平台分岔(§6.14 平台注的后果):win32 上 SIGTERM 实为硬终止且**不连带子进程**——父一死,
|
|
105
|
+
// `taskkill /PID <父> /T` 就够不到孙进程(实测:helper 孤儿存活、管道不放 ⇒ 只能等 kick)⇒
|
|
106
|
+
// win32 直接走树杀(须在树仍可寻址时执行);POSIX 保留宽限序(组杀在头进程死后仍可达全组)。
|
|
107
|
+
const armKill = () => {
|
|
108
|
+
if (process.platform === "win32") treeKill()
|
|
109
|
+
else { try { child.kill("SIGTERM") } catch { /* 已退 */ } }
|
|
110
|
+
killTimer = setTimeout(treeKill, 1500)
|
|
111
|
+
kickTimer = setTimeout(() => finish(() => settleWith(null, "SIGTERM")), 3000)
|
|
112
|
+
}
|
|
113
|
+
timer = setTimeout(() => { if (!mode) { mode = "timeout"; armKill() } }, timeoutMs)
|
|
114
|
+
// 解码 = **流级 UTF-8**(`StringDecoder` 正确拼接跨 chunk 的多字节序列——逐 chunk `toString()`
|
|
115
|
+
// 会把半截序列解成 U+FFFD;批前三档经 `execFileSync({encoding:"utf8"})` 整体解码)
|
|
116
|
+
child.stdout.setEncoding("utf8")
|
|
117
|
+
child.stderr.setEncoding("utf8")
|
|
118
|
+
child.stdout.on("data", (d) => {
|
|
119
|
+
if (mode === "overflow") return
|
|
120
|
+
stdout += d
|
|
121
|
+
outBytes += Buffer.byteLength(d)
|
|
122
|
+
// 截断按**字符**(判据只认 code + 部分输出——多字节前缀可略超 cap 字节,语义不损)
|
|
123
|
+
if (outBytes > cap) { mode = "overflow"; stdout = stdout.slice(0, cap); armKill() }
|
|
124
|
+
})
|
|
125
|
+
child.stderr.on("data", (d) => {
|
|
126
|
+
if (errBytes > cap) return // 两管各自独立计(`spawnSync` 的 maxBuffer 同口径:per-stream)
|
|
127
|
+
stderr += d
|
|
128
|
+
errBytes += Buffer.byteLength(d)
|
|
129
|
+
})
|
|
130
|
+
child.on("error", (e) => {
|
|
131
|
+
// 杀失败等信号面错误不得遮蔽已定模式(timeout / overflow 由 kick 收尾)
|
|
132
|
+
if (mode) return
|
|
133
|
+
finish(() => reject(Object.assign(e, { stdout, stderr, status: null })))
|
|
134
|
+
})
|
|
135
|
+
child.on("exit", (code, signal) => {
|
|
136
|
+
// 直接子已退但孙进程持管道 ⇒ `close` 不到:逾 1.5s 亦按已收集输出 settle(kick)
|
|
137
|
+
if (!settled && !kickTimer) kickTimer = setTimeout(() => finish(() => settleWith(code, signal)), 1500)
|
|
138
|
+
})
|
|
139
|
+
child.on("close", (code, signal) => finish(() => settleWith(code, signal)))
|
|
140
|
+
})
|
|
141
|
+
}
|
package/tools/git.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
runGit,
|
|
5
5
|
gitFailureMessage
|
|
6
6
|
} from "./shared.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { spawnGit } from "./git-run.mjs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { filterLines, runGitStrict, validateRef, gitConfigArgs, snapshotBefore, executeExtAction } from "./git-ext.mjs";
|
|
10
10
|
import { executeCheckpointAction } from "./git-checkpoint.mjs";
|
|
@@ -15,10 +15,11 @@ import { discoverRepos, MANIFEST_REL } from "../manifest.mjs";
|
|
|
15
15
|
* strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
|
|
16
16
|
* unstaged-only first line as staged. status uses this so the staged/unstaged column survives.
|
|
17
17
|
* #55 fail-closed:失败不再吞成 ""(曾把非仓 / 任意失败渲染成 `(clean — no changes)`)——
|
|
18
|
-
* 溢出保留部分输出;其余 ⇒ throw(消息契约 = `shared.mjs` `gitFailureMessage
|
|
19
|
-
|
|
18
|
+
* 溢出保留部分输出;其余 ⇒ throw(消息契约 = `shared.mjs` `gitFailureMessage`)。§6.14:体改**异步薄壳**
|
|
19
|
+
* (`spawnGit` 单点——加固 env + 两档超时 + 树杀;签名与产出形零变)。 */
|
|
20
|
+
async function runGitRaw(cwd, cmdArgs, config = []) {
|
|
20
21
|
try {
|
|
21
|
-
return
|
|
22
|
+
return (await spawnGit(cwd, [...config, ...cmdArgs], { maxBuffer: 10 * 1024 * 1024 })).replace(/\r/g, "").replace(/\n$/, "")
|
|
22
23
|
} catch (e) {
|
|
23
24
|
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) return String(e.stdout).replace(/\r/g, "")
|
|
24
25
|
throw new Error(gitFailureMessage(e, cmdArgs, cwd))
|
|
@@ -42,10 +43,14 @@ function resolveBaseDir(cwd, workdir) {
|
|
|
42
43
|
* 二者仍可经显式 `workdir` 指落点。 */
|
|
43
44
|
const NO_REPO_DISCOVERY = new Set(["init", "clone"])
|
|
44
45
|
|
|
45
|
-
/** 歧义(多态)消息:候选全列(绝对路径、按名排序)+ 指引显式 `workdir`——不猜(§6.13 解析序行 5
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/** 歧义(多态)消息:候选全列(绝对路径、按名排序)+ 指引显式 `workdir`——不猜(§6.13 解析序行 5);
|
|
47
|
+
* **两档变体**(#188——按 `matched`):带档级 ⇒ 「.git ∧ PROJECT-MANIFEST.json」;裸仓级 ⇒ 「.git」
|
|
48
|
+
* 且注明 none carries …(不书带档句——§6.13 A22)。 */
|
|
49
|
+
function ambiguousRepoMessage(candidates, cwd, matched) {
|
|
50
|
+
const head = matched === "git"
|
|
51
|
+
? `Ambiguous git repo at ${cwd}: ${candidates.length} subdirectories carry .git (none carries ${MANIFEST_REL})`
|
|
52
|
+
: `Ambiguous git repo at ${cwd}: ${candidates.length} subdirectories carry .git and ${MANIFEST_REL}`
|
|
53
|
+
return `${head} — pass workdir to run git in the intended one (no silent pick):\n` + candidates.map((c) => `- ${c}`).join("\n")
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
// ─── 审批门注入缝(#59——「只读判定 / 审批门按端注入」,形态参 §2.13.5 注入缝)────────
|
|
@@ -108,14 +113,15 @@ export const gitTool = {
|
|
|
108
113
|
// workdir: run git in a subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
|
|
109
114
|
// every action + snapshotBefore + checkpoint resolves against the workdir.
|
|
110
115
|
if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
|
|
111
|
-
// 仓发现(§6.13 · #62):缺省路径(无 workdir)⇒ 单源 = `discoverRepos
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
116
|
+
// 仓发现(§6.13 · #62):缺省路径(无 workdir)⇒ 单源 = `discoverRepos`(仓梯——#188 含裸仓级)
|
|
117
|
+
// ——工作区根(非仓)⇒ 唯一带档子仓 / 零档时唯一裸仓:重定向 `ctx.cwd` + 结果首行注记;零 ⇒ 原值
|
|
118
|
+
// 落 §6.12 fail-closed(零行为变);多 ⇒ throw(列该级候选 + 指 workdir,不猜——消息按 `matched`
|
|
119
|
+
// 两档变体)。判据 = workdir 真值在场(与上行同源——空串与缺省同判);`init` / `clone` 例外
|
|
120
|
+
// 不做发现。self / none 态 ctx 对象引用透传(不 clone)。
|
|
115
121
|
let repoNote = ""
|
|
116
122
|
if (!args.workdir && !NO_REPO_DISCOVERY.has(args.action)) {
|
|
117
123
|
const discovered = discoverRepos(ctx.cwd)
|
|
118
|
-
if (discovered.kind === "ambiguous") throw new Error(ambiguousRepoMessage(discovered.candidates, ctx.cwd))
|
|
124
|
+
if (discovered.kind === "ambiguous") throw new Error(ambiguousRepoMessage(discovered.candidates, ctx.cwd, discovered.matched))
|
|
119
125
|
if (discovered.kind === "unique") {
|
|
120
126
|
ctx = { ...ctx, cwd: discovered.root }
|
|
121
127
|
repoNote = `(repo: ${discovered.root})`
|
|
@@ -145,13 +151,16 @@ const gitActionCore = {
|
|
|
145
151
|
if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
146
152
|
const flags = args.staged ? ["--staged"] : []
|
|
147
153
|
const paths = args.path ? [args.path] : []
|
|
148
|
-
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
149
|
-
|
|
154
|
+
const out = await runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
155
|
+
// #71(hygiene-sweep 批):传了 path 且输出空 ⇒ 注记(pathspec 未命中也是 exit 0——
|
|
156
|
+
// 原样「(no changes)」为假洁净;不传 path 逐字零变)
|
|
157
|
+
const empty = args.path ? `(no changes — pathspec '${args.path}' matched nothing in ${ref})` : "(no changes)"
|
|
158
|
+
return truncate(filterLines(out || empty, args.filter))
|
|
150
159
|
}
|
|
151
160
|
case "status": {
|
|
152
161
|
// Preserve per-line leading whitespace — porcelain " M"/"M " staged/unstaged markers are
|
|
153
162
|
// significant (runGit trims the whole output's leading space, corrupting an unstaged-first-line).
|
|
154
|
-
const porcelain = runGitRaw(ctx.cwd, ["status", "--porcelain"])
|
|
163
|
+
const porcelain = await runGitRaw(ctx.cwd, ["status", "--porcelain"])
|
|
155
164
|
if (!porcelain) return "(clean — no changes)"
|
|
156
165
|
|
|
157
166
|
const staged = []
|
|
@@ -191,19 +200,21 @@ const gitActionCore = {
|
|
|
191
200
|
? ["log", "-" + n, "--oneline"]
|
|
192
201
|
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
193
202
|
if (args.path) cmdArgs.push("--", args.path)
|
|
194
|
-
const out = runGit(ctx.cwd, cmdArgs)
|
|
195
|
-
|
|
203
|
+
const out = await runGit(ctx.cwd, cmdArgs)
|
|
204
|
+
// #71:同上(log 腿——传了 path 且输出空 ⇒ 注记;不传 path 逐字零变)
|
|
205
|
+
const empty = args.path ? `(no commits — pathspec '${args.path}' matched nothing)` : "(no commits)"
|
|
206
|
+
return truncate(filterLines(out || empty, args.filter))
|
|
196
207
|
}
|
|
197
208
|
case "show": {
|
|
198
209
|
const ref = args.ref ?? "HEAD"
|
|
199
210
|
if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
200
|
-
const out = runGit(ctx.cwd, ["show", "--stat", ref])
|
|
211
|
+
const out = await runGit(ctx.cwd, ["show", "--stat", ref])
|
|
201
212
|
return truncate(out || "(no such commit)")
|
|
202
213
|
}
|
|
203
214
|
case "rm": {
|
|
204
215
|
if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
|
|
205
216
|
const paths = args.path.split(/\s+/).filter(Boolean)
|
|
206
|
-
const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
|
|
217
|
+
const r = await runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
|
|
207
218
|
return r.ok ? truncate(r.out || `Untracked ${paths.join(" ")} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
|
|
208
219
|
}
|
|
209
220
|
case "commit": {
|
|
@@ -217,12 +228,12 @@ const gitActionCore = {
|
|
|
217
228
|
if (args.path !== undefined && args.path !== null) {
|
|
218
229
|
const trimmed = args.path.trim()
|
|
219
230
|
if (!trimmed) return "Error: commit path is empty/whitespace — give at least one file path (space-separated)"
|
|
220
|
-
commit = runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
|
|
231
|
+
commit = await runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
|
|
221
232
|
} else {
|
|
222
|
-
const add = runGitStrict(ctx.cwd, ["add", "-A"])
|
|
233
|
+
const add = await runGitStrict(ctx.cwd, ["add", "-A"])
|
|
223
234
|
if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
|
|
224
235
|
if (add.out) parts.push(add.out)
|
|
225
|
-
commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
236
|
+
commit = await runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
226
237
|
}
|
|
227
238
|
if (commit.ok) {
|
|
228
239
|
if (commit.out) parts.push(commit.out)
|
|
@@ -244,7 +255,7 @@ const gitActionCore = {
|
|
|
244
255
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
245
256
|
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
246
257
|
if (args.tags) cmdArgs.push("--tags")
|
|
247
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
258
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
248
259
|
return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
|
|
249
260
|
}
|
|
250
261
|
case "ls-remote": {
|
|
@@ -253,7 +264,7 @@ const gitActionCore = {
|
|
|
253
264
|
const cmdArgs = ["ls-remote"]
|
|
254
265
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
255
266
|
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
256
|
-
const out = runGit(ctx.cwd, cmdArgs, cfgArgs)
|
|
267
|
+
const out = await runGit(ctx.cwd, cmdArgs, cfgArgs)
|
|
257
268
|
if (!out) return "(no refs / remote unreachable)"
|
|
258
269
|
return truncate(filterLines(out, args.filter))
|
|
259
270
|
}
|
|
@@ -262,51 +273,51 @@ const gitActionCore = {
|
|
|
262
273
|
// 多路径:空格分隔(ref 先例 L175——2026-09-05 发版痛点——git add 单路径被迫 N 次调用)
|
|
263
274
|
const paths = args.path ? args.path.split(/\s+/).filter(Boolean) : null
|
|
264
275
|
const cmdArgs = paths?.length ? ["add", "--", ...paths] : ["add", "-A"]
|
|
265
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
276
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
266
277
|
return r.ok ? truncate(r.out || `Staged ${paths?.join(" ") || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
|
|
267
278
|
}
|
|
268
279
|
case "tag": {
|
|
269
280
|
const sub = args.tagAction
|
|
270
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
|
|
281
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
|
|
271
282
|
if (sub === "create") {
|
|
272
283
|
if (!args.name) return "Error: tag create requires name"
|
|
273
284
|
validateRef(args.name, "tag")
|
|
274
285
|
const cmdArgs = ["tag", args.name]
|
|
275
286
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
276
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
287
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
277
288
|
return r.ok ? `Tag ${args.name} created` : truncate(`git tag failed: ${r.err || r.out}`)
|
|
278
289
|
}
|
|
279
290
|
if (sub === "delete") {
|
|
280
291
|
if (!args.name) return "Error: tag delete requires name"
|
|
281
292
|
validateRef(args.name, "tag")
|
|
282
293
|
const snap = await snapshotBefore(ctx, `tag delete ${args.name}`)
|
|
283
|
-
const r = runGitStrict(ctx.cwd, ["tag", "-d", args.name])
|
|
294
|
+
const r = await runGitStrict(ctx.cwd, ["tag", "-d", args.name])
|
|
284
295
|
return r.ok ? truncate(snap + `Tag ${args.name} deleted`) : truncate(`git tag -d failed: ${r.err || r.out}`)
|
|
285
296
|
}
|
|
286
297
|
return "Error: tag requires tagAction — use: list | create | delete"
|
|
287
298
|
}
|
|
288
299
|
case "branch": {
|
|
289
300
|
const sub = args.branchAction
|
|
290
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
|
|
301
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
|
|
291
302
|
if (sub === "create") {
|
|
292
303
|
if (!args.name) return "Error: branch create requires name"
|
|
293
304
|
validateRef(args.name, "branch")
|
|
294
305
|
const cmdArgs = ["branch", args.name]
|
|
295
306
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
296
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
307
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
297
308
|
return r.ok ? `Branch ${args.name} created` : truncate(`git branch failed: ${r.err || r.out}`)
|
|
298
309
|
}
|
|
299
310
|
if (sub === "switch") {
|
|
300
311
|
if (!args.name) return "Error: branch switch requires name"
|
|
301
312
|
validateRef(args.name, "branch")
|
|
302
|
-
const r = runGitStrict(ctx.cwd, ["checkout", args.name])
|
|
313
|
+
const r = await runGitStrict(ctx.cwd, ["checkout", args.name])
|
|
303
314
|
return r.ok ? `Switched to branch ${args.name}` : truncate(`git checkout ${args.name} failed: ${r.err || r.out}`)
|
|
304
315
|
}
|
|
305
316
|
if (sub === "delete") {
|
|
306
317
|
if (!args.name) return "Error: branch delete requires name"
|
|
307
318
|
validateRef(args.name, "branch")
|
|
308
319
|
const snap = await snapshotBefore(ctx, `branch delete ${args.name}`)
|
|
309
|
-
const r = runGitStrict(ctx.cwd, ["branch", "-d", args.name])
|
|
320
|
+
const r = await runGitStrict(ctx.cwd, ["branch", "-d", args.name])
|
|
310
321
|
return r.ok ? truncate(snap + `Branch ${args.name} deleted`) : truncate(`git branch -d failed: ${r.err || r.out}`)
|
|
311
322
|
}
|
|
312
323
|
return "Error: branch requires branchAction — use: list | create | delete | switch"
|
|
@@ -315,12 +326,12 @@ const gitActionCore = {
|
|
|
315
326
|
if (args.path) {
|
|
316
327
|
// Restore file from index (discards working-tree changes to it) — destructive: snapshot first.
|
|
317
328
|
const snap = await snapshotBefore(ctx, `checkout -- ${args.path}`)
|
|
318
|
-
const r = runGitStrict(ctx.cwd, ["checkout", "--", args.path])
|
|
329
|
+
const r = await runGitStrict(ctx.cwd, ["checkout", "--", args.path])
|
|
319
330
|
return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git checkout -- ${args.path} failed: ${r.err || r.out}`)
|
|
320
331
|
}
|
|
321
332
|
if (args.ref) {
|
|
322
333
|
validateRef(args.ref, "ref")
|
|
323
|
-
const r = runGitStrict(ctx.cwd, ["checkout", args.ref])
|
|
334
|
+
const r = await runGitStrict(ctx.cwd, ["checkout", args.ref])
|
|
324
335
|
return r.ok ? truncate(r.out || `Checked out ${args.ref}`) : truncate(`git checkout ${args.ref} failed: ${r.err || r.out}`)
|
|
325
336
|
}
|
|
326
337
|
return "Error: checkout requires ref (branch/commit) or path (file to restore)"
|
|
@@ -331,21 +342,21 @@ const gitActionCore = {
|
|
|
331
342
|
const cmdArgs = ["restore"]
|
|
332
343
|
if (args.staged) cmdArgs.push("--staged")
|
|
333
344
|
cmdArgs.push("--", args.path)
|
|
334
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
345
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
335
346
|
return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git restore failed: ${r.err || r.out}`)
|
|
336
347
|
}
|
|
337
348
|
case "stash": {
|
|
338
349
|
const sub = args.stashAction
|
|
339
|
-
if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
|
|
350
|
+
if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
|
|
340
351
|
if (sub === "push") {
|
|
341
352
|
const cmdArgs = ["stash", "push"]
|
|
342
353
|
if (args.message) cmdArgs.push("-m", args.message)
|
|
343
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
354
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
344
355
|
return r.ok ? truncate(r.out || "Stashed") : truncate(`git stash push failed: ${r.err || r.out}`)
|
|
345
356
|
}
|
|
346
357
|
if (sub === "pop") {
|
|
347
358
|
const snap = await snapshotBefore(ctx, "stash pop")
|
|
348
|
-
const r = runGitStrict(ctx.cwd, ["stash", "pop"])
|
|
359
|
+
const r = await runGitStrict(ctx.cwd, ["stash", "pop"])
|
|
349
360
|
return r.ok ? truncate(snap + (r.out || "Popped")) : truncate(`git stash pop failed: ${r.err || r.out}`)
|
|
350
361
|
}
|
|
351
362
|
return "Error: stash requires stashAction — use: push | pop | list"
|
|
@@ -354,14 +365,14 @@ const gitActionCore = {
|
|
|
354
365
|
const cmdArgs = ["fetch"]
|
|
355
366
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
356
367
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
357
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
368
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
358
369
|
return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
|
|
359
370
|
}
|
|
360
371
|
case "pull": {
|
|
361
372
|
const cmdArgs = ["pull"]
|
|
362
373
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
363
374
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
364
|
-
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
375
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
365
376
|
return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
|
|
366
377
|
}
|
|
367
378
|
case "reset": {
|
|
@@ -371,24 +382,24 @@ const gitActionCore = {
|
|
|
371
382
|
if (mode === "hard") snap = await snapshotBefore(ctx, "reset --hard") // destructive: drops working-tree changes
|
|
372
383
|
const cmdArgs = ["reset", `--${mode}`]
|
|
373
384
|
if (args.ref) cmdArgs.push(validateRef(args.ref))
|
|
374
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
385
|
+
const r = await runGitStrict(ctx.cwd, cmdArgs)
|
|
375
386
|
return r.ok ? truncate(snap + (r.out || `Reset (${mode}) complete`)) : truncate(`git reset failed: ${r.err || r.out}`)
|
|
376
387
|
}
|
|
377
388
|
case "revert": {
|
|
378
389
|
const ref = validateRef(args.ref ?? "HEAD")
|
|
379
|
-
const r = runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
|
|
390
|
+
const r = await runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
|
|
380
391
|
return r.ok ? truncate(r.out || `Reverted ${ref}`) : truncate(`git revert failed: ${r.err || r.out}`)
|
|
381
392
|
}
|
|
382
393
|
case "merge": {
|
|
383
394
|
if (!args.ref) return "Error: merge requires ref (branch/commit to merge)"
|
|
384
395
|
validateRef(args.ref, "ref")
|
|
385
|
-
const r = runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
|
|
396
|
+
const r = await runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
|
|
386
397
|
return r.ok ? truncate(r.out || `Merged ${args.ref}`) : truncate(`git merge failed: ${r.err || r.out} — resolve conflicts, then commit`)
|
|
387
398
|
}
|
|
388
399
|
case "cherry-pick": {
|
|
389
400
|
if (!args.ref) return "Error: cherry-pick requires ref (commit)"
|
|
390
401
|
validateRef(args.ref, "ref")
|
|
391
|
-
const r = runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
|
|
402
|
+
const r = await runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
|
|
392
403
|
return r.ok ? truncate(r.out || `Cherry-picked ${args.ref}`) : truncate(`git cherry-pick failed: ${r.err || r.out}`)
|
|
393
404
|
}
|
|
394
405
|
// F7 扩展 action + checkpoint:实现拆在 git-ext.mjs / git-checkpoint.mjs(500 行硬限)
|
package/tools/index.mjs
CHANGED
|
@@ -45,8 +45,10 @@ export {
|
|
|
45
45
|
// faces (memory / code+doc search / repo outline / settings / peer instances)
|
|
46
46
|
// whose factories need the shell's memory handle at run time.
|
|
47
47
|
//
|
|
48
|
-
// Host-only tools (VS Code `
|
|
48
|
+
// Host-only tools (VS Code `ide` / `focus` — TOOLS #179 ④, IDE capabilities)
|
|
49
49
|
// are NOT part of the core registry: the VS Code shell adds them itself.
|
|
50
|
+
// D-CC26(context-tool 批 2026-09-21):宿主 IDE 快照工具自 `context` **改名 `ide`**(`tools/ide.mjs`)
|
|
51
|
+
// ——让出 `context` 名给核新工具(`agent-tools/context.mjs`;同名撞车 ⇒ provider 逐字 400)。
|
|
50
52
|
//
|
|
51
53
|
// `read_image` registration follows VS Code (#70): it is registered only when the
|
|
52
54
|
// model accepts image input (`specForModel(model).multimodal`) — the conservative
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/process-tree.mjs — 平台感知树杀单源(自 `execute.mjs` 抽出——TOOLS.md §6.14 落位表行 2)。
|
|
3
|
+
* 抽出理由 = **断环**:`shared → git-run → execute → shared` 会 TDZ(`execute.mjs:29` 在模块求值期
|
|
4
|
+
* 调 `DESC`(`const`))⇒ 树杀须住两不依赖档。行为逐字同(win32 `taskkill /T /F` · POSIX 组杀 + 直杀兜底)。
|
|
5
|
+
* `execute.mjs` 保持再导出面(`test/tool-seams.test.mjs:26` 消费)。
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process"
|
|
8
|
+
|
|
9
|
+
/** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
|
|
10
|
+
* Timeout/abort must reach grandchildren: a script that spawned children keeps the
|
|
11
|
+
* pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
|
|
12
|
+
* while the orphan keeps running (2026-09-05 advisor 🟡#4). */
|
|
13
|
+
export function killProcessTree(child) {
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
16
|
+
} else {
|
|
17
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
18
|
+
try { child.kill("SIGKILL") } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/tools/shared.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync
|
|
|
8
8
|
import { dirname, join, resolve } from "node:path"
|
|
9
9
|
import { fileURLToPath } from "node:url"
|
|
10
10
|
import { loadToolDoc, applyPromptInjections } from "../prompt-files.mjs"
|
|
11
|
+
import { spawnGit, gitTimeoutNote, GIT_TIMEOUT_MS } from "./git-run.mjs"
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
14
|
// 工具描述加载面——**核内单一解析面**(D-C13 / 契约 8):解析根 = `prompt-files.mjs`
|
|
@@ -438,8 +439,10 @@ export function htmlToText(html) {
|
|
|
438
439
|
|
|
439
440
|
/** git 失败消息构造单点(#55 fail-closed——`runGit` / `runGitRaw` 两读取面同款):形态 =
|
|
440
441
|
* `git <args…> failed: <stderr 首行> (cwd: <绝对 cwd>)`;**非仓**失败(退出码 128 ∧ stderr 首行含
|
|
441
|
-
* `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。
|
|
442
|
+
* `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。
|
|
443
|
+
* **超时**(`e.timedOut`——§6.14):帧 + 单源超时注(`gitTimeoutNote`,含树杀尽义与恢复指引)。 */
|
|
442
444
|
export function gitFailureMessage(e, cmdArgs, cwd) {
|
|
445
|
+
if (e?.timedOut) return `git ${cmdArgs.join(" ")} failed: ${gitTimeoutNote(e.timeoutMs ?? GIT_TIMEOUT_MS)} (cwd: ${resolve(cwd ?? ".")})`
|
|
443
446
|
const stderr = String(e?.stderr ?? "").trim()
|
|
444
447
|
const first = stderr.split("\n")[0].trim() || String(e?.message || e?.code || "unknown error").split("\n")[0]
|
|
445
448
|
const notRepo = e?.status === 128 && first.includes("not a git repository")
|
|
@@ -449,10 +452,11 @@ export function gitFailureMessage(e, cmdArgs, cwd) {
|
|
|
449
452
|
|
|
450
453
|
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
454
|
* config: optional array of `-c key=value` overrides (e.g. ["http.proxy=http://10.2.2.112:3128"]) —
|
|
452
|
-
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy.
|
|
453
|
-
|
|
455
|
+
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy.
|
|
456
|
+
* §6.14:体改**异步薄壳**委托 `spawnGit` 单点(加固 env + 两档超时 + 树杀);trim / `\r` / 溢出 / 抛出四形逐字保留。 */
|
|
457
|
+
export async function runGit(cwd, cmdArgs, config = []) {
|
|
454
458
|
try {
|
|
455
|
-
return
|
|
459
|
+
return (await spawnGit(cwd, [...config, ...cmdArgs], { maxBuffer: 10 * 1024 * 1024 })).trim().replace(/\r/g, "")
|
|
456
460
|
} catch (e) {
|
|
457
461
|
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
458
462
|
// (callers show "(truncated)"-style tails). Every OTHER failure (non-git repo,
|
|
@@ -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
|
+
}
|