Files
homeagent-sdk/example/commondev/interrupt_zcode_e2e_test.go
JianFeeeee e1b458a710 fix(commondev): interrupt 的"是否真中断"判定改用 pi 的 isIdle + 真机 e2e
## 找不到的缺陷(变异测出来的)

静态判据全绿、真机也报 interrupted=true —— 但我把 sidecar 里的
`await session.abort()` **删掉**后,判据照样通过。原因:

	const hadInflight = inflight > 0;
	...
	interrupted: hadInflight,      // ← 只看"中断前有没有在跑"

任务还在跑 ⇒ inflight 仍 >0 ⇒ 照样回报"已中断"。**那正是最坏的情形:
用户以为停了,任务还在改代码、推分支。**

## 第二版又反向错了

改用"中断前后 inflight 的变化":

	interrupted: before > 0 && inflight < before

实测 before=1 / inflight=1 ⇒ 误报"未中断",而任务其实已经停了。
原因:inflight 是**包装层**计数,只在后台 promise 的 finally 里递减,
而 abort() 返回时那个 promise 还没收尾。

反向的谎报同样有害:用户以为没停,重复发一遍任务。

## 最终:用 pi 自己的 isIdle

	session.isIdle 是 pi 的权威视图("当前没有在跑的任务")
	interrupted: !wasIdle && session.isIdle

实测应答:{"ok":true,"interrupted":true,"was_idle":false,"now_idle":true}

## 真机 e2e(3 条,HA_PI_E2E / HA_ZCODE_E2E 控制)

 · TestRealPiInterruptStopsRunningTask
   真 pi 会话跑 60 步任务,12s 后中断 → interrupted=true,且会话仍可用
 · TestRealPiSameTaskWithoutInterruptStillRunning ★对照组
   同样任务不中断,15s 时仍在跑 ⇒ 证明"已中断"不是"任务碰巧跑完了"
   实测不中断时 >100s 仍未完成
 · TestRealZcodeInterruptKillsChildAndClearsHandle
   假 CLI(持续输出的 node 脚本)→ interrupt 真的 killed_pid,
   第二次 interrupt 回 no_running_turn(句柄已清,不误杀下一轮)

## 变异(这次全咬)

 删掉 await session.abort()             → 咬(now_idle=false 暴露任务仍在跑)
 把 interrupted 改回 hadInflight(代码行) → 咬

★ 静态判据里查 hadInflight 时**只查代码行、跳过注释** —— 全文 Contains
  会把解释性注释当成缺陷(实测误报一次)。

## 顺带

两份 pi sidecar(工程根 = 单测/SSH e2e 用,rawfile/ = 部署用)必须
逐字一致:既有判据 TestSidecarScriptShipsWithPackage 会比对。
2026-10-18 10:52:02 +08:00

137 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// 真机 e2e:zcode 的取消路径(HA_ZCODE_E2E=1 时运行)。
//
// ## 为什么单独测
//
// pi 与 zcode 的取消是**两种完全不同的机制**:
//
// pi → session.abort(),协作式中断,agent 自己走到下一个可中断点
// zcode → 整组杀掉当前回合的子进程(zcode 没有 abort API)
//
// 所以 pi 的判据证明不了 zcode 能用。这里用**假 CLI**(一个会持续输出的
// node 脚本)验证杀进程这条路径 —— 不需要真 zcode CLI 就能测:
// 判据要证的是"interrupt 真的把子进程杀了、且不误杀后续回合",
// 这与 CLI 是不是真的 zcode 无关。
import (
"bufio"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// ★★★ 判据:zcode 的 interrupt 真的杀掉子进程,且句柄正确清空。
func TestRealZcodeInterruptKillsChildAndClearsHandle(t *testing.T) {
if os.Getenv("HA_ZCODE_E2E") != "1" {
t.Skip("设 HA_ZCODE_E2E=1 以运行(用假 CLI,不需要真 zcode)")
}
root, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
nodeBin := filepath.Join(root, "rawfile", "node", "bin", "node")
if _, err := os.Stat(nodeBin); err != nil {
t.Skipf("内嵌 node 不存在: %v", err)
}
sidecarPath := filepath.Join(root, "rawfile", "runtime", "zcode", "sidecar.mjs")
if _, err := os.Stat(sidecarPath); err != nil {
t.Fatalf("zcode sidecar 不存在: %v", err)
}
dir := t.TempDir()
workdir := filepath.Join(dir, "work")
if err := os.MkdirAll(workdir, 0o755); err != nil {
t.Fatal(err)
}
// 假 CLI:node 脚本,持续输出 120 秒(足够被中断)
fake := filepath.Join(dir, "fakezcode.js")
script := `console.log(JSON.stringify({type:"start"}));
let i = 0;
const t = setInterval(() => {
i++;
console.log("tick-" + i);
if (i >= 120) {
clearInterval(t);
console.log(JSON.stringify({type:"result", sessionId:"sess_fake", response:"done"}));
}
}, 1000);
`
if err := os.WriteFile(fake, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
_, w, r := startSidecarWithExtraEnv(t, nodeBin, sidecarPath, workdir, "e2e-zcode-int", "ZCODE_CLI="+fake)
if resp := callSidecar(t, w, r, 1, "ping", nil); resp.Error != nil {
t.Fatalf("ping 失败: %s", resp.Error.Message)
}
if resp := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{"text": "跑吧"}); resp.Error != nil {
t.Fatalf("prompt 失败: %s", resp.Error.Message)
}
// 让它真的跑起来
time.Sleep(6 * time.Second)
// 第一次 interrupt:必须真的杀
resp := callSidecar(t, w, r, 3, "interrupt", nil)
if resp.Error != nil {
t.Fatalf("interrupt 失败: %s", resp.Error.Message)
}
raw := string(resp.Result)
if !strings.Contains(raw, `"interrupted":true`) {
t.Fatalf("★ zcode interrupt 没有真的中断(%s)—— 用户以为停了,任务还在跑", raw)
}
if !strings.Contains(raw, "killed_pid") {
t.Fatalf("★ interrupt 应答没有 killed_pid(%s)—— 无法确认真的杀了进程", raw)
}
t.Logf("第一次 interrupt: %s", raw)
// 第二次 interrupt:句柄必须已清空,不能误杀后续回合
time.Sleep(3 * time.Second)
resp2 := callSidecar(t, w, r, 4, "interrupt", nil)
raw2 := string(resp2.Result)
if !strings.Contains(raw2, `"interrupted":false`) {
t.Fatalf("★ 第二次 interrupt 仍报 interrupted=true(%s)—— "+
"说明 currentChild 没清空,会误杀下一轮", raw2)
}
if !strings.Contains(raw2, "no_running_turn") {
t.Fatalf("★ 第二次 interrupt 的 reason 不是 no_running_turn(%s)", raw2)
}
t.Logf("第二次 interrupt(应无回合): %s", raw2)
}
// startSidecarWithExtraEnv 在既有 helper 的基础上追加一条环境变量。
//
// 为什么需要额外 env:zcode 的 CLI 路径由 ZCODE_CLI 指定(driver.json 里
// 没有 cliPath 时)。判据用假 CLI,所以必须能注入这条变量。
func startSidecarWithExtraEnv(t *testing.T, nodeBin, sidecarPath, workdir, sessionID, extraEnv string) (*exec.Cmd, *bufio.Writer, *bufio.Reader) {
t.Helper()
cmd := exec.Command(nodeBin, sidecarPath, "--workdir", workdir, "--session-id", sessionID)
cmd.Dir = workdir
cmd.Env = append(os.Environ(),
"PI_CODING_AGENT_SESSION_DIR="+filepath.Join(workdir, ".pi-sessions"),
extraEnv,
)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
t.Fatalf("启动 sidecar 失败: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
return cmd, bufio.NewWriter(stdin), bufio.NewReader(stdout)
}