@zhushanwen/pi-base-tool-enhance 0.10.3 → 0.11.0
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/bin/taiji-fg-wrap +197 -0
- package/package.json +9 -7
- package/skills/base-tool-enhance-ext-config/SKILL.md +4 -2
- package/src/__tests__/background-lifecycle.test.ts +222 -8
- package/src/__tests__/bash-tool.test.ts +61 -7
- package/src/__tests__/config.test.ts +44 -1
- package/src/__tests__/delivery-port-assembly.test.ts +10 -9
- package/src/__tests__/exit-watch.test.ts +391 -0
- package/src/__tests__/force-patterns.test.ts +3 -3
- package/src/__tests__/index.test.ts +230 -4
- package/src/__tests__/injector-foreground-branch.test.ts +787 -0
- package/src/__tests__/maintenance-once.test.ts +81 -19
- package/src/__tests__/notify-reconcile-judgement.test.ts +83 -2
- package/src/__tests__/notify.test.ts +129 -12
- package/src/__tests__/parity-equivalence.test.ts +340 -0
- package/src/__tests__/pending-reconcile.test.ts +284 -29
- package/src/__tests__/registry.test.ts +23 -2
- package/src/__tests__/subagent-guard.test.ts +134 -1
- package/src/__tests__/task-store.test.ts +65 -2
- package/src/__tests__/user-bash-interceptor.test.ts +324 -0
- package/src/__tests__/wrapper-foreground-idle.test.ts +410 -0
- package/src/background/exit-collector.ts +1 -1
- package/src/background/exit-watch.ts +146 -0
- package/src/background/notify-reconcile-judgement.ts +16 -2
- package/src/background/notify.ts +107 -40
- package/src/background/pending-reconcile.ts +162 -48
- package/src/background/process-exit-guard.ts +1 -1
- package/src/background/registry.ts +9 -6
- package/src/background/spawn-background.ts +14 -9
- package/src/background/task-store.ts +19 -11
- package/src/bash-kill-tool.ts +6 -5
- package/src/bash-output-tool.ts +9 -9
- package/src/bash-tool.ts +480 -34
- package/src/config.ts +58 -11
- package/src/force-patterns.ts +4 -3
- package/src/index.ts +89 -19
- package/src/user-bash-interceptor.ts +305 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# taiji-fg-wrap —— 前台命令空闲转后台包装脚本
|
|
3
|
+
#
|
|
4
|
+
# 设计依据:foreground-idle-to-background 设计文档 §5 部件 1 / §2.5 关键术语 / 矩阵 B(包装期事件)。
|
|
5
|
+
# 由 execute 改写层注入到前台命令外层执行(矩阵 A5 格):
|
|
6
|
+
# taiji-fg-wrap --idle <秒> --task-id <id> --log <日志路径> --pidfile <pid文件路径> -- <cmd.sh>
|
|
7
|
+
#
|
|
8
|
+
# 行为(矩阵 B):
|
|
9
|
+
# - spawn 真实命令 `bash <cmd.sh>`:同进程组(后台子进程继承本脚本进程组,官方中止杀
|
|
10
|
+
# 本组时真实命令一并被杀——B4)、非 detached、stdin 重定向 /dev/null(对齐官方前台
|
|
11
|
+
# spawn stdio ["ignore","pipe","pipe"]——命令读 stdin 立即 EOF,而非挂住)、
|
|
12
|
+
# stdout/stderr 追加重定向 --log 文件(命令输出通道与本脚本 stdout 物理分离——
|
|
13
|
+
# 本脚本退出无管道关闭副作用,真实命令继续落盘——SIGPIPE 规避,B3 结构性保证)。
|
|
14
|
+
# - 写 pidfile:真实命令 pid + 启动时间(epoch 秒,spawn 时刻近似)。读者 = execute
|
|
15
|
+
# 改写层收尾判定(B2 已死不登记 / B3·B5 活则登记为后台任务)。
|
|
16
|
+
# - 监控循环(间隔 = sleep 0.15 + 单轮固定开销,实测单轮 182-193ms ≤ 200ms——设计
|
|
17
|
+
# §5 部件 1「循环间隔 ≤200ms 与抄送延迟上限(矩阵 B1)同源、两口径必须一致」;
|
|
18
|
+
# 校准实测(2026-10-31 darwin arm64,§9 检查点):单轮固定开销 = now_ms(perl
|
|
19
|
+
# fork ~6ms)+ log_size(wc fork ~8ms),有新增输出时 +tail ~5ms;sleep 0.2 实测
|
|
20
|
+
# 单轮 236-238ms 系统性超 200ms 口径,故取 0.15;同轮外层已取的日志 size 经参数
|
|
21
|
+
# 传入 flush_log 复用,省去第二次 wc fork):每轮先查命令死活
|
|
22
|
+
# (B6 单进程内顺序判定,天然串行无竞态)→ 死则抄完剩余输出、exit 透传真实退出码
|
|
23
|
+
# (B2);活且空闲累积超阈值(当前时刻 − 最后输出字节时刻 ≥ 阈值,真实时钟度量,
|
|
24
|
+
# 非按轮次计数)→ 打印转后台标记行、exit 0(B3,不杀命令);活且未超 → 抄送日志
|
|
25
|
+
# 新增字节到自身 stdout(B1,官方管道流式显示)。
|
|
26
|
+
#
|
|
27
|
+
# 标记协议(§5 部件 3,三方契约逐字实现,主要消费者 = 模型):
|
|
28
|
+
# [TAIJI-FG-BG] Command moved to background after Ns without output — still running,
|
|
29
|
+
# NOT finished (no exit code yet). Full log: <path> Check: bash_output {task_id:"<id>"};
|
|
30
|
+
# terminate: bash_kill {task_id:"<id>"}. It will notify on completion — DO NOT bash
|
|
31
|
+
# sleep while waiting.
|
|
32
|
+
#
|
|
33
|
+
# 已接受代价(设计登记,不加防):spawn 与写 pidfile 之间的毫秒级窗口内本脚本被杀时
|
|
34
|
+
# pidfile 缺失、B5 兜底对该次失效(命令可能残留,与现状 pi 原生执行中宿主崩溃残留同级)。
|
|
35
|
+
#
|
|
36
|
+
# 兼容性:bash 3.2(macOS /bin/bash)子集——无关联数组 / mapfile / ${var,,} 等特性。
|
|
37
|
+
# 亚秒时钟源 = perl Time::HiRes(darwin/linux 自带;空闲阈值触发精度依赖亚秒时刻,
|
|
38
|
+
# 秒级 date 会使 idle 2s 触发窗漂移至 3.2s);perl 不可用时回退秒级粒度。
|
|
39
|
+
|
|
40
|
+
set -u
|
|
41
|
+
|
|
42
|
+
usage_exit() {
|
|
43
|
+
echo "taiji-fg-wrap: usage: taiji-fg-wrap --idle <sec> --task-id <id> --log <path> --pidfile <path> -- <cmd.sh>" >&2
|
|
44
|
+
exit 64
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
IDLE_SEC=""
|
|
48
|
+
TASK_ID=""
|
|
49
|
+
LOG_PATH=""
|
|
50
|
+
PIDFILE_PATH=""
|
|
51
|
+
CMD_SH=""
|
|
52
|
+
|
|
53
|
+
while [ $# -gt 0 ]; do
|
|
54
|
+
case "$1" in
|
|
55
|
+
--idle)
|
|
56
|
+
[ $# -ge 2 ] || usage_exit
|
|
57
|
+
IDLE_SEC="$2"
|
|
58
|
+
shift 2
|
|
59
|
+
;;
|
|
60
|
+
--task-id)
|
|
61
|
+
[ $# -ge 2 ] || usage_exit
|
|
62
|
+
TASK_ID="$2"
|
|
63
|
+
shift 2
|
|
64
|
+
;;
|
|
65
|
+
--log)
|
|
66
|
+
[ $# -ge 2 ] || usage_exit
|
|
67
|
+
LOG_PATH="$2"
|
|
68
|
+
shift 2
|
|
69
|
+
;;
|
|
70
|
+
--pidfile)
|
|
71
|
+
[ $# -ge 2 ] || usage_exit
|
|
72
|
+
PIDFILE_PATH="$2"
|
|
73
|
+
shift 2
|
|
74
|
+
;;
|
|
75
|
+
--)
|
|
76
|
+
shift
|
|
77
|
+
break
|
|
78
|
+
;;
|
|
79
|
+
*)
|
|
80
|
+
echo "taiji-fg-wrap: unknown argument: $1" >&2
|
|
81
|
+
usage_exit
|
|
82
|
+
;;
|
|
83
|
+
esac
|
|
84
|
+
done
|
|
85
|
+
|
|
86
|
+
[ $# -ge 1 ] || {
|
|
87
|
+
echo "taiji-fg-wrap: missing cmd.sh path after --" >&2
|
|
88
|
+
exit 64
|
|
89
|
+
}
|
|
90
|
+
CMD_SH="$1"
|
|
91
|
+
shift
|
|
92
|
+
[ $# -eq 0 ] || {
|
|
93
|
+
echo "taiji-fg-wrap: unexpected extra arguments after cmd.sh" >&2
|
|
94
|
+
exit 64
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case "$IDLE_SEC" in
|
|
98
|
+
'' | *[!0-9]*)
|
|
99
|
+
echo "taiji-fg-wrap: --idle requires a non-negative integer (seconds), got: $IDLE_SEC" >&2
|
|
100
|
+
exit 64
|
|
101
|
+
;;
|
|
102
|
+
esac
|
|
103
|
+
for required in "$TASK_ID" "$LOG_PATH" "$PIDFILE_PATH"; do
|
|
104
|
+
[ -n "$required" ] || {
|
|
105
|
+
echo "taiji-fg-wrap: --task-id / --log / --pidfile are all required" >&2
|
|
106
|
+
usage_exit
|
|
107
|
+
}
|
|
108
|
+
done
|
|
109
|
+
[ -f "$CMD_SH" ] || {
|
|
110
|
+
echo "taiji-fg-wrap: cmd.sh not found: $CMD_SH (confirm the injector wrote the command file before invoking)" >&2
|
|
111
|
+
exit 66
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# 毫秒时刻。perl 失败回退 date +%s 的秒级粒度(精度降级,功能不中断)。
|
|
115
|
+
now_ms() {
|
|
116
|
+
local t
|
|
117
|
+
t=$(perl -MTime::HiRes=time -e 'printf("%d\n", time() * 1000)' 2>/dev/null) || t=""
|
|
118
|
+
[ -n "$t" ] || t=$(( $(date +%s) * 1000 ))
|
|
119
|
+
printf '%s\n' "$t"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# 日志当前字节数;读取失败保持传入基线(调用方传当前 offset)。
|
|
123
|
+
# wc 输出按平台可能带前导空白(macOS BSD wc 对 stdin 输入补空格),先剥空白再校验。
|
|
124
|
+
log_size() {
|
|
125
|
+
local size
|
|
126
|
+
size=$(wc -c <"$1" 2>/dev/null) || size="$2"
|
|
127
|
+
size="${size//[[:space:]]/}"
|
|
128
|
+
case "$size" in
|
|
129
|
+
'' | *[!0-9]*) size="$2" ;;
|
|
130
|
+
esac
|
|
131
|
+
printf '%s\n' "$size"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# 抄送日志新增字节到自身 stdout(B1),推进 offset。
|
|
135
|
+
# 按字节 offset 接续 = 累积缓冲形态:UTF-8 多字节字符跨批次时字节流无损(管道字节
|
|
136
|
+
# 拼接完整),仅中间批次的渲染可能临时切半,由后续批次补全——不丢不重。
|
|
137
|
+
# 输出量以快照 size 为准(head -c 截断):快照与 tail 执行之间存在毫秒级窗口,窗口内
|
|
138
|
+
# 命令新写的字节会被 tail 一并读到 EOF——若照单输出而 offset 只推进到快照值,下一轮
|
|
139
|
+
# 将重复抄送该段(同段字节两次进入抄送流)。head 按 $((size - log_offset)) 截断后,
|
|
140
|
+
# 快照后新字节留给下一轮,不重不丢。head 读满即退,tail 后续写入收 SIGPIPE 是预期
|
|
141
|
+
# (本脚本无 pipefail / errexit,不影响退出码透传)。
|
|
142
|
+
# $1 可选 = 外层本轮已取的日志 size:复用可省同轮第二次 wc fork(单轮固定开销
|
|
143
|
+
# 校准项,见文件头实测登记);缺省时内部自取(B2 收尾路径仅调一次,开销不敏感)。
|
|
144
|
+
flush_log() {
|
|
145
|
+
local size
|
|
146
|
+
if [ $# -ge 1 ]; then
|
|
147
|
+
size="$1"
|
|
148
|
+
else
|
|
149
|
+
size=$(log_size "$LOG_PATH" "$log_offset")
|
|
150
|
+
fi
|
|
151
|
+
if [ "$size" -gt "$log_offset" ]; then
|
|
152
|
+
tail -c +$((log_offset + 1)) "$LOG_PATH" | head -c $((size - log_offset))
|
|
153
|
+
log_offset="$size"
|
|
154
|
+
fi
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# spawn 真实命令:同进程组(继承)、非 detached、stdin=/dev/null(读即 EOF)、
|
|
158
|
+
# stdout/stderr 追加落日志。日志文件由改写层保证父目录存在;append 而非 truncate。
|
|
159
|
+
bash "$CMD_SH" >>"$LOG_PATH" 2>&1 </dev/null &
|
|
160
|
+
child_pid=$!
|
|
161
|
+
|
|
162
|
+
# 写 pidfile(spawn 后毫秒级窗口内完成;该窗口被杀时 pidfile 缺失 = 设计已接受代价)。
|
|
163
|
+
# pidStartTimeSec 为 spawn 时刻近似值(epoch 秒)——跨平台 ps lstart 解析(BSD/GNU
|
|
164
|
+
# date 语法分裂 + locale 脆弱)不值当;秒级精度下近似值与进程真实启动时刻几乎总同秒,
|
|
165
|
+
# pid 复用防御的精确身份校验由消费方按 getProcessStartTimeSec 复核。
|
|
166
|
+
pid_spawn_ms=$(now_ms)
|
|
167
|
+
printf '{"pid":%d,"pidStartTimeSec":%d}\n' "$child_pid" "$((pid_spawn_ms / 1000))" >"$PIDFILE_PATH"
|
|
168
|
+
|
|
169
|
+
idle_threshold_ms=$((IDLE_SEC * 1000))
|
|
170
|
+
log_offset=0
|
|
171
|
+
last_byte_ms=$(now_ms)
|
|
172
|
+
|
|
173
|
+
while true; do
|
|
174
|
+
sleep 0.15
|
|
175
|
+
|
|
176
|
+
# B6:每轮先查死活(判死 = kill -0 ESRCH = bash 已收割后台作业,wait 立即返回状态)
|
|
177
|
+
if ! kill -0 "$child_pid" 2>/dev/null; then
|
|
178
|
+
flush_log # B2:抄完剩余输出再透传
|
|
179
|
+
wait "$child_pid" 2>/dev/null
|
|
180
|
+
exit $?
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
# B1:先感知新增输出刷新空闲计时(防「刚有输出未感知即误判空闲」),再判空闲
|
|
184
|
+
now=$(now_ms)
|
|
185
|
+
size=$(log_size "$LOG_PATH" "$log_offset")
|
|
186
|
+
if [ "$size" -gt "$log_offset" ]; then
|
|
187
|
+
flush_log "$size"
|
|
188
|
+
last_byte_ms="$now"
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
# B3:空闲累积超阈值(真实时钟静默时长,非轮次计数)→ 不杀命令,打印标记行,exit 0
|
|
192
|
+
if [ $((now - last_byte_ms)) -ge "$idle_threshold_ms" ]; then
|
|
193
|
+
elapsed_s=$(( (now - last_byte_ms) / 1000 ))
|
|
194
|
+
printf '[TAIJI-FG-BG] Command moved to background after %ds without output — still running, NOT finished (no exit code yet). Full log: %s Check: bash_output {task_id:"%s"}; terminate: bash_kill {task_id:"%s"}. It will notify on completion — DO NOT bash sleep while waiting.\n' "$elapsed_s" "$LOG_PATH" "$TASK_ID" "$TASK_ID"
|
|
195
|
+
exit 0
|
|
196
|
+
fi
|
|
197
|
+
done
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design decisions documented in source comments)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -25,15 +25,17 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"src/",
|
|
27
27
|
"index.ts",
|
|
28
|
-
"skills/"
|
|
28
|
+
"skills/",
|
|
29
|
+
"bin/"
|
|
29
30
|
],
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@zhushanwen/extension-protocol": "0.
|
|
32
|
-
"@zhushanwen/pi-delivery-port": "0.
|
|
33
|
-
"@zhushanwen/pi-ext-guards": "0.5.
|
|
32
|
+
"@zhushanwen/extension-protocol": "0.27.0",
|
|
33
|
+
"@zhushanwen/pi-delivery-port": "0.4.0",
|
|
34
|
+
"@zhushanwen/pi-ext-guards": "0.5.1",
|
|
34
35
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
35
36
|
"@zhushanwen/pi-file-lock": "0.4.1",
|
|
36
|
-
"@zhushanwen/pi-llm-shared": "0.12.
|
|
37
|
+
"@zhushanwen/pi-llm-shared": "0.12.1",
|
|
38
|
+
"@zhushanwen/subagent-core": "2.2.0"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
39
41
|
"@vitest/coverage-v8": "^4.1.9",
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
44
46
|
"@earendil-works/pi-tui": "^1.0.0",
|
|
45
47
|
"typebox": "1.3.27",
|
|
46
|
-
"@zhushanwen/pi-pending-notifications": "0.11.
|
|
48
|
+
"@zhushanwen/pi-pending-notifications": "0.11.3"
|
|
47
49
|
},
|
|
48
50
|
"peerDependenciesMeta": {
|
|
49
51
|
"@earendil-works/pi-tui": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: base-tool-enhance-ext-config
|
|
3
|
-
description: "使用或排查 @zhushanwen/pi-base-tool-enhance 的 bash 后台/白名单配置时加载。说明配置文件路径与热重载生效时机、
|
|
3
|
+
description: "使用或排查 @zhushanwen/pi-base-tool-enhance 的 bash 后台/白名单配置时加载。说明配置文件路径与热重载生效时机、6 键 schema(forceBackgroundPatterns / disableBuiltinForcePatterns / foregroundTimeoutSeconds / backgroundTimeoutSeconds / idleBackgroundSec / maxConcurrentBackground)与默认值、force-background 白名单命中语义(测试命令自动转后台、忽略显式 timeout、返回 task_id)、用户正则自动命令位置锚定约定(无需自带 ^)。触发词:bash background、后台任务配置、force-background 白名单、测试命令自动后台、dev server 自动后台、backgroundTimeoutSeconds、foregroundTimeoutSeconds、idleBackgroundSec、maxConcurrentBackground、base-tool-enhance-ext-config。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# base-tool-enhance 配置指南
|
|
@@ -23,7 +23,7 @@ description: "使用或排查 @zhushanwen/pi-base-tool-enhance 的 bash 后台/
|
|
|
23
23
|
|
|
24
24
|
文件不存在 / JSON 解析失败 → 全默认值继续工作(工具不报错,warn 落扩展日志)。单键类型错/非法值 → **仅该键**回退默认 + warn,不整体拒载;未知键忽略(前向兼容)。
|
|
25
25
|
|
|
26
|
-
## 字段表(
|
|
26
|
+
## 字段表(6 键)
|
|
27
27
|
|
|
28
28
|
| 字段 | 类型 | 默认 | 说明 |
|
|
29
29
|
|------|------|------|------|
|
|
@@ -31,6 +31,7 @@ description: "使用或排查 @zhushanwen/pi-base-tool-enhance 的 bash 后台/
|
|
|
31
31
|
| `disableBuiltinForcePatterns` | boolean | `false` | `true` = 关闭内置 force-test / force-longrun 两组,只用用户正则 |
|
|
32
32
|
| `foregroundTimeoutSeconds` | number \| null | `null` | `null` = 不注入(pi 原生不限时);正数 = 前台命令未填 timeout 时注入的默认秒数。非正数/非有限数 → 回退 null + warn;超 int32 毫秒上限自动 clamp |
|
|
33
33
|
| `backgroundTimeoutSeconds` | number \| null | `null` | 同上,后台任务未填 timeout 时的默认秒数 |
|
|
34
|
+
| `idleBackgroundSec` | number \| null | `600` | 前台命令输出静默满此值转后台(不杀命令,只解除阻塞);显式 null = 关闭机制;坏值回退默认 + warn,小数 floor 至 ≥1 |
|
|
34
35
|
| `maxConcurrentBackground` | number | `8` | 后台任务并发上限,正整数(小数取 floor) |
|
|
35
36
|
|
|
36
37
|
示例(最小可用——只关内置白名单 + 给用户正则 + 后台默认超时):
|
|
@@ -41,6 +42,7 @@ description: "使用或排查 @zhushanwen/pi-base-tool-enhance 的 bash 后台/
|
|
|
41
42
|
"forceBackgroundPatterns": ["pnpm\\s+typecheck", "make\\s+-j\\d+"],
|
|
42
43
|
"foregroundTimeoutSeconds": null,
|
|
43
44
|
"backgroundTimeoutSeconds": 600,
|
|
45
|
+
"idleBackgroundSec": 600,
|
|
44
46
|
"maxConcurrentBackground": 8
|
|
45
47
|
}
|
|
46
48
|
```
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
// 属「代码 + OS 组合行为」,本地默认跳过(门控 SSOT = test-guard/integration-gate.ts)。
|
|
16
16
|
// - 预算:单测用例走 vitest 默认;集成组用例单独挂 20s(原文件级预算随拆组下沉到
|
|
17
17
|
// 用例级,非抬升),轮询 deadline 常量(10s / 8s)仅集成组消费。
|
|
18
|
-
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
19
20
|
import { tmpdir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
21
22
|
import { EventEmitter } from "node:events";
|
|
22
23
|
|
|
23
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { BashOperations, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
25
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
25
26
|
|
|
27
|
+
import { clearConfigCache } from "@zhushanwen/pi-llm-shared";
|
|
28
|
+
|
|
26
29
|
import { INTEGRATION_TESTS_ENABLED, INTEGRATION_TESTS_SKIP_REASON } from "../../../../../test-guard/integration-gate.ts";
|
|
27
30
|
|
|
28
31
|
// getAgentDir → 测试临时数据目录(工具 execute 内部调用,不写真 ~/.pi/agent)。
|
|
@@ -86,6 +89,7 @@ import type { BackgroundTask, RegistryEntry } from "../background/types.ts";
|
|
|
86
89
|
import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
|
|
87
90
|
import { clearTaskStoreForTest, getActiveTasks, getTask, markKillingIntent } from "../background/task-store.ts";
|
|
88
91
|
import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
|
|
92
|
+
import { handleUserBash } from "../user-bash-interceptor.ts";
|
|
89
93
|
|
|
90
94
|
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-data-"));
|
|
91
95
|
|
|
@@ -353,7 +357,7 @@ describe("bash_output tool", () => {
|
|
|
353
357
|
await expect(outputTool({ task_id: "bt-none" })).rejects.toThrow(/No such task/);
|
|
354
358
|
});
|
|
355
359
|
|
|
356
|
-
it("P0-1: list merges only TERMINAL registry entries — running entries stay invisible
|
|
360
|
+
it("P0-1: list merges only TERMINAL registry entries — running entries stay invisible", async () => {
|
|
357
361
|
// 独立 session 目录:单例表空 + registry 含 running 与 exited 两条他进程条目
|
|
358
362
|
// (模拟 resume 被强杀 session:runtime 收殓转终态前的窗口里 registry 残留 running)
|
|
359
363
|
const sid = "sess-p0-list";
|
|
@@ -369,7 +373,7 @@ describe("bash_output tool", () => {
|
|
|
369
373
|
expect(ids).not.toContain("bt-xrun"); // running 条目不并入——无幻影 running 行
|
|
370
374
|
});
|
|
371
375
|
|
|
372
|
-
it("P0-1: detail registry fallback is terminal-only — cross-process running entry not queryable
|
|
376
|
+
it("P0-1: detail registry fallback is terminal-only — cross-process running entry not queryable", async () => {
|
|
373
377
|
const sid = "sess-p0-detail";
|
|
374
378
|
const path = getRegistryPath(DATA_DIR, sid);
|
|
375
379
|
writeRegistryEntry(path, makeRegistryEntry({ taskId: "bt-xrun2", state: "running" }));
|
|
@@ -383,7 +387,7 @@ describe("bash_output tool", () => {
|
|
|
383
387
|
expect(detail.exitCode).toBe(3);
|
|
384
388
|
});
|
|
385
389
|
|
|
386
|
-
it("deleted output file degrades to <lost> without crashing
|
|
390
|
+
it("deleted output file degrades to <lost> without crashing", async () => {
|
|
387
391
|
const task = spawnOk("sleep 0.1 && echo gone");
|
|
388
392
|
emitFakeExit(task, 0);
|
|
389
393
|
unlinkSync(task.outputFile);
|
|
@@ -816,9 +820,9 @@ describe("D15: abort signal does not propagate to background tasks", () => {
|
|
|
816
820
|
});
|
|
817
821
|
});
|
|
818
822
|
|
|
819
|
-
// ──────────────────────── 单测层:spawn
|
|
823
|
+
// ──────────────────────── 单测层:spawn 失败路径 ────────────────────────
|
|
820
824
|
|
|
821
|
-
describe("spawn failure paths
|
|
825
|
+
describe("spawn failure paths", () => {
|
|
822
826
|
it("nonexistent cwd reports the builtin-style error", () => {
|
|
823
827
|
const result = spawnBackgroundTask({
|
|
824
828
|
command: "echo hi",
|
|
@@ -1007,3 +1011,213 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)(
|
|
|
1007
1011
|
);
|
|
1008
1012
|
},
|
|
1009
1013
|
);
|
|
1014
|
+
|
|
1015
|
+
// ──────────────────────── 集成组:user_bash 拦截层真进程形态(M2/U2,RL4/RL5/B5)────────────────────────
|
|
1016
|
+
//
|
|
1017
|
+
// completion-signal-delivery M2 门控集成(testCommand 第二条精确覆盖本组):真 spawn
|
|
1018
|
+
// taiji-fg-wrap + 真实时钟空闲触发(idleBackgroundSec=2 经配置注入)+ 真信号组杀。
|
|
1019
|
+
// RL4 转后台 = settled 的 exec 侧前提(resolve 不 reject)/ RL5 abort 通道组杀 / B5
|
|
1020
|
+
// wrapper 异常死亡兜底登记。真实时钟与进程信号时序无确定性完成信号——挂门控
|
|
1021
|
+
// (test-guard/integration-gate.ts),等待写法 = 等 exec promise + pollUntilTicked。
|
|
1022
|
+
describe.skipIf(!INTEGRATION_TESTS_ENABLED)(
|
|
1023
|
+
`user_bash interceptor real process (M2/U2: idle-to-background / abort / B5)${INTEGRATION_TESTS_SKIP_REASON ? `|skip:${INTEGRATION_TESTS_SKIP_REASON}` : ""}`,
|
|
1024
|
+
() => {
|
|
1025
|
+
const TASKS_DIR = dirname(REGISTRY_PATH);
|
|
1026
|
+
const USER_MARKER_PREFIX = "[TAIJI-FG-BG] 已转后台";
|
|
1027
|
+
// 空闲阈值注入:config 文件 idleBackgroundSec=2(门控用例秒级实测,先例 --idle 2 形态)
|
|
1028
|
+
let configDir = "";
|
|
1029
|
+
// 本组快照(相对断言基线):同文件既有用例共享 DATA_DIR,磁盘 registry 与
|
|
1030
|
+
// tasks 目录非空,「无本次残留」必须按快照差集断言,不能断言全空
|
|
1031
|
+
let taskIdsBefore = new Set<string>();
|
|
1032
|
+
let filesBefore = new Set<string>();
|
|
1033
|
+
|
|
1034
|
+
function createMockPi() {
|
|
1035
|
+
return { events: { emit: vi.fn() }, sendMessage: vi.fn(), appendEntry: vi.fn() };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** 轮询直到 getter 产出非 undefined 值(pidfile 出现等文件系统就绪信号)。 */
|
|
1039
|
+
async function pollForValue<T>(get: () => T | undefined, deadlineMs: number, what: string): Promise<T> {
|
|
1040
|
+
const deadline = Date.now() + deadlineMs;
|
|
1041
|
+
for (;;) {
|
|
1042
|
+
const value = get();
|
|
1043
|
+
if (value !== undefined) return value;
|
|
1044
|
+
if (Date.now() > deadline) {
|
|
1045
|
+
throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
|
|
1046
|
+
}
|
|
1047
|
+
await sleep(25);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function snapshotBefore(): void {
|
|
1052
|
+
taskIdsBefore = new Set(readRegistry(REGISTRY_PATH).keys());
|
|
1053
|
+
filesBefore = new Set(existsSync(TASKS_DIR) ? readdirSync(TASKS_DIR) : []);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** 本次 exec 新产生的磁盘文件(与快照差集)。 */
|
|
1057
|
+
function newFilesSince(): string[] {
|
|
1058
|
+
return (existsSync(TASKS_DIR) ? readdirSync(TASKS_DIR) : []).filter((f) => !filesBefore.has(f));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* 等 pidfile 出现并返回(wrapperPid, cmdPid):pidfile 写的是真实命令 pid(wrapper
|
|
1063
|
+
* 协议),wrapper 自身 pid = 命令的进程组 id(wrapper detached 成组长)——经
|
|
1064
|
+
* ps 查 pgid 得组长(B5 单杀 wrapper 用)。
|
|
1065
|
+
*/
|
|
1066
|
+
async function waitForPidfile(): Promise<{ wrapperPid: number; cmdPid: number; pidfilePath: string }> {
|
|
1067
|
+
const pidfilePath = await pollForValue(() => {
|
|
1068
|
+
const file = readdirSync(TASKS_DIR).find((f) => f.endsWith(".pid") && !filesBefore.has(f));
|
|
1069
|
+
return file === undefined ? undefined : join(TASKS_DIR, file);
|
|
1070
|
+
}, POLL_DEADLINE_MS, "pidfile to appear");
|
|
1071
|
+
const cmdPid = (JSON.parse(readFileSync(pidfilePath, "utf8")) as { pid: number }).pid;
|
|
1072
|
+
const pgid = Number.parseInt(execFileSync("ps", ["-o", "pgid=", "-p", String(cmdPid)], { encoding: "utf8" }).trim(), 10);
|
|
1073
|
+
return { wrapperPid: pgid, cmdPid, pidfilePath };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** 经真实 handler(格判定 + 构造)取包装 ops(门控组默认档全命中)。 */
|
|
1077
|
+
function wrappedOps(command: string): BashOperations["exec"] {
|
|
1078
|
+
const handlerResult = handleUserBash(
|
|
1079
|
+
{ type: "user_bash", command, excludeFromContext: false, cwd: process.cwd() },
|
|
1080
|
+
makeCtx() as never,
|
|
1081
|
+
);
|
|
1082
|
+
if (handlerResult === undefined || !("operations" in handlerResult)) {
|
|
1083
|
+
throw new Error(`expected wrapped operations for: ${command}`);
|
|
1084
|
+
}
|
|
1085
|
+
return handlerResult.operations.exec;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
beforeEach(() => {
|
|
1089
|
+
spawnMode.real = true;
|
|
1090
|
+
snapshotBefore();
|
|
1091
|
+
// idle 阈值 2s:config 注入(loadBaseToolEnhanceConfig 读时刷新即生效)
|
|
1092
|
+
configDir = mkdtempSync(join(tmpdir(), "bte-ubash-cfg-"));
|
|
1093
|
+
vi.stubEnv("PI_CODING_AGENT_DIR", configDir);
|
|
1094
|
+
const configPath = join(configDir, "config", "base-tool-enhance-ext-config.json");
|
|
1095
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
1096
|
+
writeFileSync(configPath, `${JSON.stringify({ idleBackgroundSec: 2 })}\n`, "utf8");
|
|
1097
|
+
clearConfigCache();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
afterEach(() => {
|
|
1101
|
+
clearConfigCache();
|
|
1102
|
+
if (configDir !== "") {
|
|
1103
|
+
rmSync(configDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1104
|
+
configDir = "";
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it(
|
|
1109
|
+
"RL4 转后台形态:空闲阈值触发 → exec resolve({exitCode:null}) 不 reject + 登记运行态 + 用户向标记行 + 三文件保留(settled 已执行已收尾的 exec 侧前提)",
|
|
1110
|
+
{ timeout: 20_000 },
|
|
1111
|
+
async () => {
|
|
1112
|
+
const pi = createMockPi();
|
|
1113
|
+
refreshPiReference(pi as unknown as ExtensionAPI);
|
|
1114
|
+
const chunks: Buffer[] = [];
|
|
1115
|
+
// sleep 4 > idle 2:转后台后命令继续跑;输出静默 2s 触发 wrapper 标记行
|
|
1116
|
+
const result = await wrappedOps("sleep 4 && echo done")("sleep 4 && echo done", process.cwd(), {
|
|
1117
|
+
onData: (chunk: Buffer) => chunks.push(chunk),
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// exec resolve 不 reject、exitCode null(官方组装层呈现 exitCode undefined +
|
|
1121
|
+
// cancelled false,AS11 → rpc success → runtime settled 回执;bash 通道占用
|
|
1122
|
+
// 随 RPC 返回复位——D3 V6 断言的通道复用前提)
|
|
1123
|
+
expect(result).toEqual({ exitCode: null });
|
|
1124
|
+
|
|
1125
|
+
// 登记:单例表 running + registry running + pending:register emit
|
|
1126
|
+
const task = getActiveTasks()[0];
|
|
1127
|
+
expect(task).toBeDefined();
|
|
1128
|
+
expect(task?.command).toBe("sleep 4 && echo done");
|
|
1129
|
+
expect(readRegistry(REGISTRY_PATH).get(task!.taskId)?.state).toBe("running");
|
|
1130
|
+
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
1131
|
+
"pending:register",
|
|
1132
|
+
{ id: task!.taskId, type: "bash", name: "sleep 4 && echo done" },
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
// 用户向标记行经 onData 到达(D3 产品形态①)。禁词断言只约束 exec 补发的
|
|
1136
|
+
// 用户向行——wrapper 协议标记行(模型向,含 bash_output/bash_kill 语法)经
|
|
1137
|
+
// 抄送流先到是三方协议原样透传,不属于本断言对象
|
|
1138
|
+
const output = Buffer.concat(chunks).toString("utf8");
|
|
1139
|
+
const userMarker = output.split("\n").find((line) => line.startsWith(USER_MARKER_PREFIX));
|
|
1140
|
+
expect(userMarker).toBeDefined();
|
|
1141
|
+
expect(userMarker).toContain("侧边栏");
|
|
1142
|
+
expect(userMarker).not.toContain("bash_output");
|
|
1143
|
+
expect(userMarker).not.toContain("bash_kill");
|
|
1144
|
+
|
|
1145
|
+
// 三文件保留(转后台任务日志 = bash_output 数据源)
|
|
1146
|
+
expect(existsSync(task!.outputFile)).toBe(true);
|
|
1147
|
+
expect(existsSync(join(TASKS_DIR, `${task!.taskId}.sh`))).toBe(true);
|
|
1148
|
+
expect(existsSync(join(TASKS_DIR, `${task!.taskId}.pid`))).toBe(true);
|
|
1149
|
+
|
|
1150
|
+
// 守望收尾链可达(先例设施零重建的端到端证据):命令自然退出后 exited + 通知
|
|
1151
|
+
await pollUntilTicked(() => getTask(task!.taskId)?.state === "exited", POLL_DEADLINE_MS, "watch finalization");
|
|
1152
|
+
expect(getTask(task!.taskId)?.reason).toBe("natural");
|
|
1153
|
+
expect(pi.sendMessage).toHaveBeenCalled();
|
|
1154
|
+
const notified = pi.sendMessage.mock.calls[0]![0] as { customType: string };
|
|
1155
|
+
expect(notified.customType).toBe("background-bash");
|
|
1156
|
+
},
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
it(
|
|
1160
|
+
"RL5 abort 通道:signal 中止 → wrapper 进程组整组被杀(真实命令无残留)+ exec 抛 aborted + 无登记 + 三文件清理",
|
|
1161
|
+
{ timeout: 20_000 },
|
|
1162
|
+
async () => {
|
|
1163
|
+
const pi = createMockPi();
|
|
1164
|
+
refreshPiReference(pi as unknown as ExtensionAPI);
|
|
1165
|
+
const controller = new AbortController();
|
|
1166
|
+
const execPromise = wrappedOps("sleep 300")("sleep 300", process.cwd(), {
|
|
1167
|
+
onData: () => {},
|
|
1168
|
+
signal: controller.signal,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// 等 wrapper 写 pidfile(spawn 后毫秒级窗口)取真实命令 pid,供组杀后判死断言
|
|
1172
|
+
const { cmdPid } = await waitForPidfile();
|
|
1173
|
+
expect(cmdPid).toBeGreaterThan(0);
|
|
1174
|
+
|
|
1175
|
+
controller.abort();
|
|
1176
|
+
// 官方式 aborted 上抛(executor catch signal.aborted → cancelled: true 组装)
|
|
1177
|
+
await expect(execPromise).rejects.toThrow("aborted");
|
|
1178
|
+
|
|
1179
|
+
// 组杀生效:真实命令(与 wrapper 同组)轮询判死(SIGKILL 生效延迟满载不可预估)
|
|
1180
|
+
await pollUntilTicked(() => !isPidAlive(cmdPid), POLL_DEADLINE_MS, "aborted command group to die");
|
|
1181
|
+
|
|
1182
|
+
// 用户中止无登记(与 agent 侧 B4 同语义):运行时权威(单例表)零新增 +
|
|
1183
|
+
// 磁盘 registry 零新增条目 + 三文件零残留(同文件既有用例共享 DATA_DIR,
|
|
1184
|
+
// 按快照差集断言)+ 通知链零接触
|
|
1185
|
+
expect(getActiveTasks()).toHaveLength(0);
|
|
1186
|
+
expect([...readRegistry(REGISTRY_PATH).keys()].filter((id) => !taskIdsBefore.has(id))).toEqual([]);
|
|
1187
|
+
expect(newFilesSince()).toEqual([]);
|
|
1188
|
+
expect(pi.events.emit).not.toHaveBeenCalled();
|
|
1189
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
1190
|
+
},
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
it(
|
|
1194
|
+
"B5 兜底:wrapper 异常死亡(单杀 wrapper 自身)且命令进程存活 → 照常登记转后台 + 用户向标记行补发 + resolve({exitCode:null})",
|
|
1195
|
+
{ timeout: 20_000 },
|
|
1196
|
+
async () => {
|
|
1197
|
+
const pi = createMockPi();
|
|
1198
|
+
refreshPiReference(pi as unknown as ExtensionAPI);
|
|
1199
|
+
const chunks: Buffer[] = [];
|
|
1200
|
+
const execPromise = wrappedOps("sleep 30")("sleep 30", process.cwd(), {
|
|
1201
|
+
onData: (chunk: Buffer) => chunks.push(chunk),
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// 等 pidfile 出现后单杀 wrapper 自身(wrapper pid = 命令的 pgid,组长;单杀
|
|
1205
|
+
// 组长非组杀——真实命令同组存活 = B5 判定形态「wrapper 异常死亡且命令进程活着」)
|
|
1206
|
+
const { wrapperPid } = await waitForPidfile();
|
|
1207
|
+
process.kill(wrapperPid, "SIGKILL");
|
|
1208
|
+
|
|
1209
|
+
const result = await execPromise;
|
|
1210
|
+
expect(result).toEqual({ exitCode: null });
|
|
1211
|
+
|
|
1212
|
+
// 照常登记(命令进程活着 = 承诺在)+ 用户向标记行补发(wrapper 未及打印)
|
|
1213
|
+
const task = getActiveTasks()[0];
|
|
1214
|
+
expect(task).toBeDefined();
|
|
1215
|
+
expect(task?.command).toBe("sleep 30");
|
|
1216
|
+
expect(readRegistry(REGISTRY_PATH).get(task!.taskId)?.state).toBe("running");
|
|
1217
|
+
expect(Buffer.concat(chunks).toString("utf8")).toContain(USER_MARKER_PREFIX);
|
|
1218
|
+
// 登记的原 pid 仍是存活的命令进程(B5 兜底语义:任务接管不丢)
|
|
1219
|
+
expect(isPidAlive(task!.pid)).toBe(true);
|
|
1220
|
+
},
|
|
1221
|
+
);
|
|
1222
|
+
},
|
|
1223
|
+
);
|