mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 14:03:17 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a7ebbc2a4 | |||
| 40a84a0635 | |||
| fa5b1b4840 | |||
| ae53299ad3 | |||
| 472d908862 | |||
| 64a2238bde | |||
| de890aadd6 | |||
| 3da060f5a1 | |||
| 16839d5166 | |||
| 4f4e2feac1 | |||
| fa54738c6a | |||
| 3ba4609bd3 | |||
| bd4ba5c98d | |||
| a6c7d850d2 |
199
.github/workflows/ci.yml
vendored
Normal file
199
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,199 @@
|
||||
# HomeAgent 主仓 CI。
|
||||
#
|
||||
# 设计原则:**CI 里跑的每一条命令,都是本地已实测通过的命令**。
|
||||
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态,
|
||||
# 最后所有人学会忽略它。
|
||||
#
|
||||
# 覆盖范围与本地 `make test` 对齐(build / vet / test / client-versions /
|
||||
# gui / csrc),并按依赖拆成独立 job,便于失败定位。
|
||||
#
|
||||
# 明确**不在** CI 里跑的东西(依赖真机/密钥/内网,跑了只会变 flaky 噪音):
|
||||
# - deploy-*.sh / homed 生产部署
|
||||
# - waiter 真机验证(192.168.2.x)
|
||||
# - cmd/gui 的 `npm run test-live`(需真 Electron + Xvfb + 真后端)
|
||||
# - scripts/kernel-stress/*(需 llmsproxy 与压测端点)
|
||||
# - 需要 DEEPSEEK_API_KEY / MEDIALIVE_* 的真实 LLM 测试(已自带 t.Skip)
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# 只读权限:CI 不需要写仓库。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# 同一分支连续推送时取消旧跑,省额度也避免过期结果误导。
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# gojieba / onnx 相关包需要 cgo ⇒ 不能用 CGO_ENABLED=0。
|
||||
CGO_ENABLED: 1
|
||||
# 减少 go test 输出噪音。
|
||||
GOFLAGS: -buildvcs=false
|
||||
|
||||
jobs:
|
||||
# ── Go 后端:构建 + 静态检查 + 全量测试 + 跨平台客户端版本一致性 ──
|
||||
go:
|
||||
name: Go build / vet / test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# cgo 需要 gcc/g++(gojieba 会编译自带 C++ 源码)。
|
||||
- name: 确认 cgo 工具链
|
||||
run: |
|
||||
gcc --version | head -1
|
||||
g++ --version | head -1
|
||||
|
||||
- name: go build ./...
|
||||
run: go build ./...
|
||||
|
||||
- name: go vet ./...
|
||||
run: go vet ./...
|
||||
|
||||
# ./... 不点名 cmd/gui(该目录是纯 Electron,无 .go 文件):
|
||||
# 显式 `go test ./cmd/gui` 会报 "no Go files",那是误报,不是缺陷。
|
||||
- name: go test ./...
|
||||
run: go test ./... -count=1 -timeout 20m
|
||||
|
||||
# 跨平台客户端版本一致性:内核 internal/meta 是唯一事实源,
|
||||
# GUI(package.json) / 鸿蒙(AppScope/app.json5) / waiter 都必须跟它一致。
|
||||
- name: 客户端版本一致性
|
||||
run: make check-client-versions
|
||||
|
||||
# ── 竞态检测(并发改动的主要防线)──
|
||||
race:
|
||||
name: Race detector
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: go test -race(并发核心)
|
||||
run: |
|
||||
go test -race \
|
||||
./internal/agent/core/ ./cmd/waiter/ \
|
||||
-count=1 -timeout 15m
|
||||
|
||||
# ── 交叉编译:可在无 cgo 下构建的客户端/工具 ──
|
||||
#
|
||||
# 只有这三个 cmd 支持纯交叉编译。另外三个依赖 cgo(gojieba / onnx):
|
||||
# homed / memgc / homed-kb-migrate → internal/memory(gojieba)
|
||||
# homed → internal/agent/api(onnx)
|
||||
# 它们必须在原生平台构建(见 Makefile 的 build target)。
|
||||
cross:
|
||||
name: Cross-compile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
ext: ".exe"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: 构建 ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
for c in waiter initconfig mock-server; do
|
||||
out="dist/${c}_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
|
||||
go build -trimpath -o "$out" "./cmd/${c}"
|
||||
echo " ✓ ${c} ${{ matrix.goos }}/${{ matrix.goarch }}"
|
||||
done
|
||||
|
||||
# ── Electron GUI(纯 Node 测试,零依赖)──
|
||||
#
|
||||
# `npm test` 只跑三个 .mjs,全部只 import node: 内置模块(fs/url/path/vm),
|
||||
# 所以**不需要 npm ci、不需要 electron**,秒级完成。
|
||||
# `npm run test-live` 需真 Electron + 真后端 ⇒ 不进 CI。
|
||||
gui:
|
||||
name: GUI (node)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: npm test
|
||||
working-directory: cmd/gui
|
||||
run: npm test
|
||||
|
||||
# ── C 基础设施门禁(ABI / 告警 / ASan+UBSan / 跨架构)──
|
||||
csrc:
|
||||
name: C infrastructure gates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# clang 供双编译器告警对照;gcc-aarch64 供跨架构编译门禁。
|
||||
# 门禁在缺工具时是显式 SKIP 而不是假通过,这里装齐以免静默降级。
|
||||
- name: 安装 C 工具链
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq cmake clang gcc-aarch64-linux-gnu
|
||||
|
||||
- name: make check-csrc
|
||||
run: make check-csrc
|
||||
|
||||
# ── 文档站构建(mkdocs,纯 Python,无外部依赖)──
|
||||
docs:
|
||||
name: Docs build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: 校验站点配置可解析
|
||||
# 这里只做「配置与文档源没坏」的轻量校验,不做完整 mkdocs build
|
||||
# (站点发布有独立流水线,见 deploy-sdk-site.sh)。
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f mkdocs.yml ]; then
|
||||
python -c \
|
||||
"import yaml; yaml.safe_load(open('mkdocs.yml'))" \
|
||||
&& echo "mkdocs.yml OK"
|
||||
else
|
||||
echo "无 mkdocs.yml,跳过"
|
||||
fi
|
||||
test -d docs || echo "无 docs/,跳过"
|
||||
389
.github/workflows/release.yml
vendored
Normal file
389
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,389 @@
|
||||
# 发布流水线:release/** 分支推送即发版。
|
||||
#
|
||||
# 设计依据 docs/git-branching.md §七(发版产物清单)与 git-release-discipline
|
||||
# skill。核心事实:**推 tag ≠ 完成发版** —— 完整发版是四件事:
|
||||
# bump meta.Version → 打 tag → 打包产物 → 建 release 条目并上传附件。
|
||||
# (v1.3.1–v1.3.6 曾只推了 tag,产物与 release 条目全缺,事后补做。)
|
||||
#
|
||||
# 版本号来源:internal/meta/meta.go 的 Version(唯一事实源)。
|
||||
# 所以发版动作 = 在 release/vX.Y.x 上把 meta.Version 改成目标版本后推送。
|
||||
# 版本未变的推送(如改文档)会因 tag 已存在而**整轮跳过**,不会重复发版。
|
||||
#
|
||||
# 发版前的 go test 门可以显式跳过(见下面 skip_tests 的说明)。
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_tests:
|
||||
description: '跳过发版前的 go test 门(仅用于已知红的历史维护线)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# 发布必须能写仓库(打 tag、建 release、传附件)。
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# 发布不允许并发/取消:半途中断会留下 tag 存在但附件不全的状态。
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# gojieba 需要 cgo;onnxruntime 版本经 dlopen 加载,编译期无需装 ORT。
|
||||
CGO_ENABLED: 1
|
||||
GOFLAGS: -buildvcs=false
|
||||
# CI 用的大资产(模型/运行库)存于这个 release。
|
||||
ASSETS_TAG: ci-assets-v1
|
||||
|
||||
jobs:
|
||||
# ── 读版本号并判断是否需要发版 ──
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
prerelease: ${{ steps.ver.outputs.prerelease }}
|
||||
exists: ${{ steps.ver.outputs.exists }}
|
||||
skip_tests: ${{ steps.ver.outputs.skip_tests }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# 发版前的 go test 门为什么可以跳过:
|
||||
#
|
||||
# 新旧发布线的测试健康状况不同。实测 release/v1.3.x(历史维护线)上
|
||||
# internal/plugins 的 TestRealPlugin_DeepSearchKeepsSharedBackendOnStop
|
||||
# 失败、GUI 尚无 npm test 脚本、csrc 基础设施不存在 —— 而这三项在 main
|
||||
# 上都正常。给旧线补新流水线等于用今天的门去量旧代码,硬门会让该线
|
||||
# **完全无法发版**。
|
||||
#
|
||||
# 故:默认严格(测试必跑);发版人若确知该线测试是既存红的,可在
|
||||
# 发版 commit 里写 [skip-release-tests] 显式跳过 —— 决定因此记录在
|
||||
# **定义该次发版的那个 commit** 里,git 历史可审计。
|
||||
# go build 仍是硬门(产物不可能建立在编译失败的代码上)。
|
||||
- id: ver
|
||||
name: 读取 meta.Version 并检查 tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \
|
||||
internal/meta/meta.go | head -1)
|
||||
if [ -z "$V" ]; then
|
||||
echo "ERROR: 无法从 internal/meta/meta.go 读出 Version"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$V" >> "$GITHUB_OUTPUT"
|
||||
# SemVer 预发布(1.3.13-beta.1)⇒ release 标记为预发布
|
||||
case "$V" in
|
||||
*-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
# 幂等闸门:tag 已存在说明该版本发过了,整轮跳过。
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v$V" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo " tag v$V 已存在 —— 跳过发版"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo " 将为 v$V 发版"
|
||||
fi
|
||||
|
||||
# 是否跳过发版前的 go test 门(默认不跳)。
|
||||
# 两个来源:手动触发的输入,或发版 commit 里的显式标记。
|
||||
# 后者使决定落在定义该次发版的 commit 上,可以从 git 历史审计。
|
||||
#
|
||||
# 标记查在**改动 meta.Version 的那个提交**上,而不是 HEAD:
|
||||
# 发版提交之后往往还会跟几个提交(如同步 workflow、改文档),
|
||||
# 若只看 HEAD,标记就会被后续提交顶掉,静默失效。
|
||||
SKIP="${{ inputs.skip_tests }}"
|
||||
MARKER=0
|
||||
REL_COMMIT=$(git log -1 --format=%H -- internal/meta/meta.go)
|
||||
REL_MSG=$(git log -1 --pretty=%B "$REL_COMMIT")
|
||||
case "$REL_MSG" in
|
||||
*'[skip-release-tests]'*) MARKER=1 ;;
|
||||
*) MARKER=0 ;;
|
||||
esac
|
||||
echo " 发版提交: ${REL_COMMIT:0:12}"
|
||||
if [ "$SKIP" = "true" ] || [ "$MARKER" = "1" ]; then
|
||||
echo "skip_tests=true" >> "$GITHUB_OUTPUT"
|
||||
echo ""
|
||||
echo " ⚠️ **已请求跳过发版前的 go test 门**"
|
||||
echo " 来源:${SKIP} = true / commit 标记 = $MARKER"
|
||||
echo " 后果:产物可能建立在单元测试失败的代码上。"
|
||||
echo " 理由应当记录在发版 commit 的正文里。"
|
||||
else
|
||||
echo "skip_tests=false" >> "$GITHUB_OUTPUT"
|
||||
echo " 发版前会跑 go test 门(可用 [skip-release-tests] 标记跳过)"
|
||||
fi
|
||||
|
||||
# ── 构建 Linux 产物(amd64)──
|
||||
#
|
||||
# 三个 deb + 一个 tar.gz,总约 2.4GB(server/full/tar 含 719MB 模型)。
|
||||
# 编译不需要 ONNX Runtime —— onnxruntime_go 是 dlopen 方式,运行期才加载
|
||||
# libonnxruntime.so;但**打包**需要它(要打进 deb),故从 ASSETS_TAG 下载。
|
||||
build-linux:
|
||||
name: Build linux/amd64
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 确认 cgo 工具链
|
||||
run: |
|
||||
gcc --version | head -1
|
||||
g++ --version | head -1
|
||||
|
||||
# 发版前的门。
|
||||
#
|
||||
# go build 是**硬门**:产物不可能建立在编译失败的代码上。
|
||||
# go test 默认也跑,但可在发版 commit 里写 [skip-release-tests] 跳过
|
||||
# —— 历史维护线的既有红测试不应阻断该线的一切发版(详见 prepare job)。
|
||||
- name: go build(硬门)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go build ./...
|
||||
|
||||
- name: go test(发版前验证)
|
||||
if: needs.prepare.outputs.skip_tests != 'true'
|
||||
run: go test ./... -count=1 -timeout 20m
|
||||
|
||||
- name: go test 被跳过(显式声明的后果)
|
||||
if: needs.prepare.outputs.skip_tests == 'true'
|
||||
run: |
|
||||
echo "::warning title=go test 门已跳过::本次发版未跑 go test,产物可能建立在单元测试失败的代码上。"
|
||||
|
||||
# GUI 依赖 Electron 运行时。打包脚本从两处找它:
|
||||
# 1) ~/.cache/electron 里的 electron-v<ver>-linux-<arch>.zip
|
||||
# 2) cmd/gui/node_modules/electron/dist(同架构时)
|
||||
# 全新 runner 两处都没有 —— 而脚本在都没有时**只能跳过 GUI**,
|
||||
# 于是 client/full 包会静默地不含界面(这正是脚本作者担心的“假包”)。
|
||||
# 所以这里显式装一份:npm 会解析出 ^33.0.0 的实际版本并落到
|
||||
# node_modules,脚本便走第 2 条路径(runner 是 amd64 == 目标架构)。
|
||||
#
|
||||
# ⚠️ 不能用 `npm install --production`(那会跳过 devDependencies,
|
||||
# 而 electron 正是 devDependency)。
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: 安装 Electron(GUI 打包需要)
|
||||
working-directory: cmd/gui
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 用 npm ci 而非 `npm install electron@<range>`:后者是非确定性的
|
||||
# (range 会随上游发布漂到新版本),且锁不住传递依赖。
|
||||
# package-lock.json 里已锁定 electron(lockfileVersion 3),
|
||||
# ci 严格按 lock 安装,同一个 commit 永远得到同一套依赖。
|
||||
npm ci --no-audit --no-fund
|
||||
test -f node_modules/electron/dist/electron
|
||||
echo " 已就绪:$(node_modules/electron/dist/electron --version)"
|
||||
|
||||
- name: 下载构建资产(模型 + ONNX Runtime)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="https://github.com/${GITHUB_REPOSITORY}/releases/download/${ASSETS_TAG}"
|
||||
mkdir -p /tmp/assets/model /tmp/assets/ort
|
||||
for f in chinese-clip-vit-b16-onnx.tar \
|
||||
onnxruntime-linux-amd64-1.28.0.tar SHA256SUMS; do
|
||||
echo " 下载 $f"
|
||||
curl -sSL --retry 3 -o "/tmp/assets/$f" "$BASE/$f"
|
||||
done
|
||||
# 校验(资产是构建输入,损坏会打出坏包)
|
||||
(cd /tmp/assets && sha256sum -c SHA256SUMS)
|
||||
tar -xf /tmp/assets/chinese-clip-vit-b16-onnx.tar \
|
||||
-C /tmp/assets/model
|
||||
ORT_TAR=/tmp/assets/onnxruntime-linux-amd64-1.28.0.tar
|
||||
tar -xf "$ORT_TAR" -C /tmp/assets/ort
|
||||
echo " 模型文件:"
|
||||
ls /tmp/assets/model/chinese-clip-vit-b16-onnx
|
||||
echo " ORT 文件:"
|
||||
ls /tmp/assets/ort
|
||||
|
||||
- name: 打包(tar.gz + full/server/client deb)
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
CHINESECLIP_BUNDLE_DIR: /tmp/assets/model/chinese-clip-vit-b16-onnx
|
||||
ONNXRUNTIME_ASSET_DIR: /tmp/assets/ort
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash deploy/packaging/package-linux.sh amd64 all
|
||||
|
||||
- name: 平铺产物(附件必须同目录,SHA256SUMS 用平铺名)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/out
|
||||
cp dist/linux/deb/*.deb /tmp/out/
|
||||
cp dist/linux/tar/*.tar.gz /tmp/out/
|
||||
cp dist/linux/SHA256SUMS /tmp/out/
|
||||
echo " 产物:"
|
||||
for f in /tmp/out/*; do
|
||||
printf " %8.1fMB %s\n" \
|
||||
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")"
|
||||
done
|
||||
|
||||
- name: 验证产物(deb 元数据 + 校验和自验)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /tmp/out
|
||||
for f in *.deb; do
|
||||
echo " $f"
|
||||
dpkg-deb -f "$f" Package Version Architecture | sed 's/^/ /'
|
||||
done
|
||||
# full/server 必须真的带模型,否则是“默认启用但装完不能用”的假包。
|
||||
#
|
||||
# ★ 不能用 grep -q:它匹配到就退出,关闭管道读端,dpkg-deb 内部
|
||||
# 的 tar 写 stdout 时收到 EPIPE(“stdout: write error”),
|
||||
# 在 pipefail 下整条 pipeline 变成失败 —— 检测项本身是好的,
|
||||
# 却被检测手段误杀(首次试发布就死在这里)。改用 >/dev/null,
|
||||
# grep 会读完整个输入再退出,不产生 SIGPIPE。
|
||||
dpkg-deb -c homeagent-full_*_amd64.deb \
|
||||
| grep "chinese-clip-vit-b16-onnx/TextEncoder.onnx" >/dev/null
|
||||
echo " ✓ full 包含模型"
|
||||
dpkg-deb -c homeagent-full_*_amd64.deb \
|
||||
| grep "libonnxruntime.so" >/dev/null
|
||||
echo " ✓ full 包含 ONNX Runtime"
|
||||
dpkg-deb -c homeagent-server_*_amd64.deb \
|
||||
| grep "chinese-clip-vit-b16-onnx/TextEncoder.onnx" >/dev/null
|
||||
echo " ✓ server 包含模型"
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: linux-amd64
|
||||
path: /tmp/out/*
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# ── 建 tag、建 release、上传附件 ──
|
||||
publish:
|
||||
name: Publish
|
||||
needs: [prepare, build-linux]
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-amd64
|
||||
path: dist
|
||||
|
||||
- name: 打 tag(打在触发本次发版的 commit 上)
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: 建 release 并上传附件
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
PRE: ${{ needs.prepare.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
FLAGS=()
|
||||
[ "$PRE" = "true" ] && FLAGS+=(--prerelease)
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--notes "HomeAgent $VERSION
|
||||
|
||||
产物清单与校验见 SHA256SUMS。
|
||||
- \`homeagent_${VERSION}_linux_amd64.tar.gz\` — 内核 + CLI + GUI 打包
|
||||
- \`homeagent-client_${VERSION}_amd64.deb\` — 客户端
|
||||
- \`homeagent-server_${VERSION}_amd64.deb\` — 服务端(含向量模型)
|
||||
- \`homeagent-full_${VERSION}_amd64.deb\` — 全量" \
|
||||
"${FLAGS[@]}" \
|
||||
./*.deb ./*.tar.gz ./SHA256SUMS
|
||||
echo "=== release 内容 ==="
|
||||
gh release view "$TAG" --json assets \
|
||||
--jq '.assets[] | " \(.name) \(.size) 字节"'
|
||||
|
||||
- name: 回读校验(下载回来验证附件可读且校验和成立)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/back
|
||||
cd /tmp/back
|
||||
gh release download "$TAG"
|
||||
for f in *; do
|
||||
printf " %8.1fMB %s\n" \
|
||||
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$f"
|
||||
done
|
||||
sha256sum -c SHA256SUMS
|
||||
echo " ✓ 回读校验通过"
|
||||
|
||||
# ── 同步到 gitcode(国内镜像)──
|
||||
#
|
||||
# 需要仓库 secret GITCODE_TOKEN;未配置则跳过(不阻断 GitHub 侧发布)。
|
||||
# gitcode 的 release 附件是"同名只写一次",故只在此处上传一次。
|
||||
sync-gitcode:
|
||||
name: Sync to gitcode
|
||||
needs: [prepare, publish]
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: tok
|
||||
name: 检查 gitcode 凭据
|
||||
run: |
|
||||
if [ -n "${{ secrets.GITCODE_TOKEN }}" ]; then
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
echo " 未配置 GITCODE_TOKEN —— 跳过 gitcode 同步"
|
||||
fi
|
||||
- uses: actions/download-artifact@v8
|
||||
if: steps.tok.outputs.ok == 'true'
|
||||
with:
|
||||
name: linux-amd64
|
||||
path: dist
|
||||
- name: 推 tag 与附件到 gitcode
|
||||
if: steps.tok.outputs.ok == 'true'
|
||||
env:
|
||||
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 1) 推 tag(附件上传前 release 条目必须先存在)
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "$TAG" 2>/dev/null || true
|
||||
GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com"
|
||||
git push "${GC_URL}/JianFeeeee/HomeAgent.git" "$TAG"
|
||||
# 2) 建 release 条目
|
||||
curl -sS --max-time 60 -X POST \
|
||||
-H "private-token: ${GC_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://gitcode.com/api/v5/repos/JianFeeeee/HomeAgent/releases" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \
|
||||
-o /tmp/.gcrel -w " 建 release → %{http_code}\n"
|
||||
# 3) 上传附件(用仓库既有脚本,它处理 OBS 预签名两步流程)
|
||||
cd dist
|
||||
python3 ../deploy/scripts/upload_assets.py "$TAG" "$GC_TOKEN" \
|
||||
./*.deb ./*.tar.gz ./SHA256SUMS
|
||||
@ -1 +0,0 @@
|
||||
1 ICON "E:/program/homeagent/homeagent/build/icon.ico"
|
||||
Binary file not shown.
@ -203,18 +203,28 @@ spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependenc
|
||||
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
|
||||
print(m.group(1) if m else '')
|
||||
" 2>/dev/null || true)
|
||||
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
|
||||
if [ -n "$ever" ]; then
|
||||
echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$gui_out"
|
||||
|
||||
# 优先:缓存里的目标架构 zip(~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)
|
||||
#
|
||||
# ★ 这里必须 `|| true`:`find` 对**不存在的目录**返回退出码 1,而本脚本是
|
||||
# `set -euo pipefail`,命令替换里的失败会让整个脚本当场退出。
|
||||
# 后果:任何**没有 ~/.cache/electron 的机器**(全新克隆、CI runner、
|
||||
# 其他开发机)跑到这里就死,且只留下一行「electron 版本取自 package.json」
|
||||
# 作为最后的输出,看不出真因。实测(2026-09-29,GitHub runner 与本地
|
||||
# 移走缓存后均复现):build_go 全部成功,然后卡在这里静默退出。
|
||||
# 本机历史上之所以一直「能打包」,只是因为碰巧有那份缓存。
|
||||
local zip=""
|
||||
if [ -n "$ever" ]; then
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1 || true)
|
||||
fi
|
||||
if [ -z "$zip" ]; then
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
|
||||
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1 || true)
|
||||
fi
|
||||
|
||||
if [ -n "$zip" ]; then
|
||||
@ -620,9 +630,11 @@ build_rpm() {
|
||||
if [ ! -f "$rpmbuild_dir/usr/bin/rpmbuild" ]; then
|
||||
# try to extract from cached deb packages
|
||||
local rpm_deb
|
||||
rpm_deb="$(find /tmp -name "rpm_*.deb" -type f 2>/dev/null | head -1)"
|
||||
# 同 build_gui:`find` 对不存在/无命中会返回 1,`set -euo pipefail` 下
|
||||
# 会让脚本当场退出(`|| true` 是给命令替换兜底,不是忽视错误)。
|
||||
rpm_deb="$(find /tmp -name "rpm_*.deb" -type f 2>/dev/null | head -1 || true)"
|
||||
if [ -z "$rpm_deb" ]; then
|
||||
rpm_deb="$(find "$PROJECT_ROOT" -name "rpm_*.deb" -type f 2>/dev/null | head -1)"
|
||||
rpm_deb="$(find "$PROJECT_ROOT" -name "rpm_*.deb" -type f 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
if [ -n "$rpm_deb" ]; then
|
||||
mkdir -p "$rpmbuild_dir"
|
||||
|
||||
@ -404,9 +404,30 @@ func (a *Agent) Start() {
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
|
||||
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
|
||||
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
|
||||
// (设计 §7 I5、§11.3 X2/X4)。必须在 cancel 之前做:cancel 会让调度器直接 return。
|
||||
a.drainPendingInterrupts("agent_stopped")
|
||||
a.cancel()
|
||||
}
|
||||
|
||||
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
|
||||
// skipped 终态(复用 emitSkippedReply:非阻塞写,不对外发 agent_output 事件)。
|
||||
func (a *Agent) drainPendingInterrupts(reason string) {
|
||||
if a.sched == nil {
|
||||
return
|
||||
}
|
||||
pending := a.sched.pendingEvents()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
for _, evt := range pending {
|
||||
a.emitSkippedReply(evt, reason)
|
||||
}
|
||||
log.Printf("[agent] %s: 停机,%d 条待办任务已补 skipped 终态", a.id, len(pending))
|
||||
}
|
||||
|
||||
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
|
||||
//
|
||||
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
|
||||
|
||||
@ -331,13 +331,20 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5(每任务恰一次
|
||||
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
|
||||
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
|
||||
select {
|
||||
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
Target: evt.Source,
|
||||
Type: "text",
|
||||
Payload: payload,
|
||||
Done: true,
|
||||
OutputChannel: ch,
|
||||
}:
|
||||
default:
|
||||
log.Printf("[agent] ResponseCh 已满,终态回执被丢弃(request=%s,可能违反不变量 I5)", evt.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -198,8 +198,13 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
|
||||
|
||||
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
|
||||
//
|
||||
// inboundChannelName 只拼名字,不产生副作用(注销路径要用它算出同一个名字,
|
||||
// 不能再去调 residentInboundChannel——那会顺手把刚摘掉的登记又写回去)。
|
||||
func inboundChannelName(childID string) string { return "child/" + childID }
|
||||
|
||||
func (a *Agent) residentInboundChannel(childID string) string {
|
||||
ch := "child/" + childID
|
||||
ch := inboundChannelName(childID)
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
// 归属父自己:它是父的入站 inputch。
|
||||
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
|
||||
@ -246,6 +251,14 @@ func (a *Agent) teardownResident(rc *residentChild) {
|
||||
for _, ch := range rc.inputChs {
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
}
|
||||
// 注销"父接收该子消息"的入站 inputch(child/<id>)。
|
||||
//
|
||||
// 它由 residentInboundChannel 在 create 时登记(Owner=父),销毁时必须
|
||||
// 一并摘掉:登记表是共享的、按 name 全局唯一,残留会随 create/destroy
|
||||
// 次数单调累积脏数据。实测:destroy 后 child/<id> 仍挂在根 agent 名下,
|
||||
// 而外部没有任何工具能单独注销 inputch,只能重启 homed 清。
|
||||
// 注意用纯函数算名字,不要再走 residentInboundChannel(会重新登记)。
|
||||
reg.Unregister(inboundChannelName(rc.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -142,6 +142,11 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
|
||||
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
|
||||
}
|
||||
// 父的入站 inputch(child/<id>)必须在销毁时一并注销,否则登记表残留脏数据——
|
||||
// HomeAgent 实测:destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
|
||||
if inbound, ok := reg.Lookup("child/child-1"); ok {
|
||||
t.Fatalf("销毁后父的入站 inputch 应被注销,实际残留:%+v", inbound)
|
||||
}
|
||||
if err := parent.DestroyResident("child-1"); err == nil {
|
||||
t.Fatal("重复销毁应报错")
|
||||
}
|
||||
@ -159,6 +164,9 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
|
||||
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
|
||||
}
|
||||
if _, ok := reg.Lookup(inboundChannelName(id)); ok {
|
||||
t.Fatalf("父退出后子 %s 的入站 inputch 应被注销", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -229,6 +229,10 @@ type SchedulerStats struct {
|
||||
Executed uint64
|
||||
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
|
||||
Rejected uint64
|
||||
// Backpressure 是就绪队列满、输入被挡回 channel 的次数
|
||||
// (设计 §4.4 / §11.4 Q4:满时阻塞发送方,**必须计数并打日志**)。
|
||||
// 与 Rejected 的区别:Rejected 是「丢了」,Backpressure 是「暂时不收、发送方在等」。
|
||||
Backpressure uint64
|
||||
// Suspended / Resumed 是挂起与恢复的次数。
|
||||
// 不变量:系统排空后 Suspended == Resumed(挂起必然被恢复),
|
||||
// 因此两者各自只在**一处**计数(suspend / resumeTask)。
|
||||
@ -284,7 +288,13 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
|
||||
Rejected: snap.Stats.Rejected,
|
||||
Suspended: snap.Stats.Suspended,
|
||||
Resumed: snap.Stats.Resumed,
|
||||
Preempted: snap.Stats.Suspended,
|
||||
Backpressure: snap.Stats.Backpressure,
|
||||
}
|
||||
// Preempted 是「各级抢占成功次数之和」,**不是** Suspended:受害者可能在
|
||||
// 让位信号生效前就自行结束,此时有抢占而没有挂起(见 PreemptsByLevel 注释)。
|
||||
// 此前这里直接拿 Suspended 顶替,导致 DTO 里 preempted 与 preempts_by_level 自相矛盾。
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
out.Preempted += snap.Stats.PreemptsByLevel[lv]
|
||||
}
|
||||
if snap.Running != nil {
|
||||
out.Running = &sdk.SchedulerTask{
|
||||
@ -320,6 +330,9 @@ type scheduler struct {
|
||||
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
|
||||
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
|
||||
critical atomic.Bool
|
||||
// backpressured 记录「就绪队列满」这一状态的翻转,用于只打一次日志。
|
||||
// 满着的时候 pumpInbox 每轮都会走到,逐轮打日志会把日志刷爆。
|
||||
backpressured bool
|
||||
// wake 用于把空闲的调度器叫醒:pendingInterrupts 不是 channel,
|
||||
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
|
||||
wake chan struct{}
|
||||
@ -381,6 +394,28 @@ func (s *scheduler) hasRoom() bool {
|
||||
return len(s.queue) < s.maxQueue
|
||||
}
|
||||
|
||||
// noteBackpressure 记一次背压,并报告这是否是「从有空间 → 满」的翻转。
|
||||
//
|
||||
// 为什么需要翻转信息:满的时候每轮泵入都会调用本函数,逐轮打日志会刷爆;
|
||||
// 而设计 §4.4 要求「必须计数并打日志」——两者靠这个布尔量同时满足。
|
||||
func (s *scheduler) noteBackpressure() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stats.Backpressure++
|
||||
if s.backpressured {
|
||||
return false
|
||||
}
|
||||
s.backpressured = true
|
||||
return true
|
||||
}
|
||||
|
||||
// clearBackpressure 在就绪队列重新可收(泵空)时复位翻转标记。
|
||||
func (s *scheduler) clearBackpressure() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.backpressured = false
|
||||
}
|
||||
|
||||
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
|
||||
func (s *scheduler) allocateIDLocked(t *Task) {
|
||||
s.seq++
|
||||
@ -490,10 +525,22 @@ func (s *scheduler) interruptCountLocked() int {
|
||||
// setImmediateLocked 登记一个应“立即运行”的抢占者。
|
||||
//
|
||||
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
// 返回 true 表示 t **确实占住了 immediate 槽**;false 表示它被降级进了自己的
|
||||
// 级别队列(immediate 是单槽,这是设计要求的降级分支,见设计 §2「至多一个」)。
|
||||
//
|
||||
// 调用方必须用返回值决定是否计入 PreemptsByLevel:那条计数器的语义是
|
||||
// 「进入 immediate 的次数」,被降级的中断从未进过 immediate。
|
||||
// setImmediateLocked 尝试把 t 放进 immediate 槽。
|
||||
//
|
||||
// 返回 true:t 已占住 immediate(若原有抢占者被顶掉,它**已被**降级入队)。
|
||||
// 返回 false:t 没有进 immediate,且本函数**未动 t** —— 调用方负责按级别入队。
|
||||
//
|
||||
// 把「降级入队」的责任留给调用方,是为了让「到底入队了几次」只有一个出口:
|
||||
// 早先由本函数在返回 false 前自行入队,调用方又照着 false 再入一次,
|
||||
// 同一任务就会在队列里出现两份(实测:中断任务被执行两次、Executed 虚高)。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) bool {
|
||||
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
|
||||
s.enqueueInterruptLocked(t)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if s.immediate != nil {
|
||||
s.enqueueInterruptLocked(s.immediate)
|
||||
@ -501,6 +548,7 @@ func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.immediate = t
|
||||
return true
|
||||
}
|
||||
|
||||
func removeTask(list []*Task, target *Task) []*Task {
|
||||
@ -571,11 +619,16 @@ func (s *scheduler) registerInterrupt(t *Task) bool {
|
||||
arm := false
|
||||
if !critical && canPreempt(t, running) {
|
||||
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
s.setImmediateLocked(t)
|
||||
// 只有**真的占住 immediate 槽**才算一次抢占,才计入 PreemptsByLevel:
|
||||
// immediate 是单槽,若它被另一个更高级的抢占者占着,t 会走上而下的
|
||||
// 「否则入队」分支——那种情况 t 从未进入 immediate(否则同一安全点前
|
||||
// 到达两条同级中断时该计数会高估)。
|
||||
if s.setImmediateLocked(t) {
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !arm {
|
||||
@ -606,6 +659,54 @@ func (s *scheduler) clearPreempt() {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// rearmPending 在**安全点重新求值**中断队列(设计 §4.3 / §5.2)。
|
||||
//
|
||||
// 为什么必须有这一步:中断只在 registerInterrupt 里被武装一次,而那一刻运行任务
|
||||
// 可能正在临界区(S_TOOL_EXEC / ONNX / CAS)或处于抢占冷却期,于是请求只能入队。
|
||||
// 若安全点不再回头看队列,它就永远等不到执行——只能等当前任务**自然结束**,
|
||||
// 这违背设计承诺的「临界区期间到达的抢占请求……在临界区结束后的第一个安全点
|
||||
// 重新求值」。可复现症状:WebUI 终止按钮连按两次,第二次(落在 2s 冷却窗内)
|
||||
// 入队后再也不会被求值,「终止」看起来没反应。
|
||||
//
|
||||
// 判据与 registerInterrupt **完全同一套**(canPreempt + 冷却 + 临界区闸门),
|
||||
// 因此不会凭空制造设计之外的抢占。
|
||||
func (s *scheduler) rearmPending() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 已有让位信号、或 immediate 槽已被占用:下一个安全点的选择已经在路上,
|
||||
// 不必(也不该)重复武装。
|
||||
if s.preemptArmed || s.immediate != nil || s.running == nil || s.critical.Load() {
|
||||
return
|
||||
}
|
||||
// 冷却期内不武装:与 registerInterrupt 同一判据(抗饥饿)。
|
||||
if !s.running.LastPreemptAt.IsZero() && time.Since(s.running.LastPreemptAt) < preemptCooldown {
|
||||
return
|
||||
}
|
||||
// 中断队列本就按级别组织:从最高级往下找第一条能抢占的队头。
|
||||
// (队列里的任务有效级恒等于基础级,故「第一条能抢」= 最高级可抢占者。)
|
||||
for lv := LevelCritical; lv >= LevelBackground; lv-- {
|
||||
q := s.interruptQueues[lv]
|
||||
if len(q) == 0 {
|
||||
continue
|
||||
}
|
||||
t := q[0]
|
||||
if !canPreempt(t, s.running) {
|
||||
continue
|
||||
}
|
||||
s.popInterruptLocked(lv)
|
||||
if s.setImmediateLocked(t) {
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
} else {
|
||||
// immediate 槽没拿到(理论上进不来,顶部已判 immediate == nil):放回队列,
|
||||
// 否则任务会凭空消失。
|
||||
s.enqueueInterruptLocked(t)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// suspend 保存现场。
|
||||
//
|
||||
// 深度上界是**结构推论**(= 中断级数),不是配置项:安全点上的 canSuspend 已提前
|
||||
@ -638,6 +739,37 @@ func (s *scheduler) canSuspend() bool {
|
||||
return len(s.suspendStack) < s.maxInterruptFrames
|
||||
}
|
||||
|
||||
// pendingEvents 收集**尚未执行**(排队队列 / 四条中断队列 / immediate)与
|
||||
// **已挂起**(中断栈)任务所携带的、且带同步回执通道的输入事件。
|
||||
//
|
||||
// 用途只有一个:停机收尾。这些任务不会再被调度,若不给它们补终态,
|
||||
// 无超时的同步注入方(cli / clawhubadapter)会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||||
func (s *scheduler) pendingEvents() []*agentIO.InputEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*agentIO.InputEvent
|
||||
add := func(t *Task) {
|
||||
if t != nil && t.Event != nil && t.Event.ResponseCh != nil {
|
||||
out = append(out, t.Event)
|
||||
}
|
||||
}
|
||||
for _, t := range s.queue {
|
||||
add(t)
|
||||
}
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
for _, t := range s.interruptQueues[lv] {
|
||||
add(t)
|
||||
}
|
||||
}
|
||||
add(s.immediate)
|
||||
for _, f := range s.suspendStack {
|
||||
if f != nil {
|
||||
add(f.Task)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// done 标记任务执行结束。
|
||||
func (s *scheduler) done(t *Task) {
|
||||
s.mu.Lock()
|
||||
@ -796,9 +928,11 @@ func (a *Agent) schedulerLoop() {
|
||||
// 无待办:阻塞等新输入、新中断(wake)或退出。
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
}
|
||||
case msg := <-a.selfInputCh:
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
_ = a.sched.enqueue(newSelfTask(msg))
|
||||
case <-a.sched.wake:
|
||||
// 中断已入 pendingInterrupts,回到循环顶部重新挑选。
|
||||
case <-a.ctx.Done():
|
||||
@ -825,15 +959,28 @@ func (a *Agent) pumpInbox() {
|
||||
for a.sched.hasRoom() {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
// 返回值必须处理:静默丢弃会让同步调用方永久挂起(回执路径 E)。
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
a.sched.noteBackpressure()
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
}
|
||||
case msg := <-a.selfInputCh:
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
// 自循环输入没有同步调用方,满时记一次背压即可。
|
||||
if !a.sched.enqueue(newSelfTask(msg)) {
|
||||
a.sched.noteBackpressure()
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
a.sched.clearBackpressure()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 队列满:输入留在 channel 里,发送方阻塞(设计 §4.4「阻塞发送方」)。
|
||||
// 必须计数并打日志——否则运维看到 Rejected=0 会以为没背压,而输入正卡在 channel。
|
||||
if a.sched.noteBackpressure() {
|
||||
log.Printf("[agent] ready queue full (%d), input channel backpressured", a.sched.maxQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
|
||||
|
||||
186
internal/agent/core/scheduler_rearm_test.go
Normal file
186
internal/agent/core/scheduler_rearm_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package core
|
||||
|
||||
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
|
||||
//
|
||||
// 对照设计稿原文修正的四条:
|
||||
//
|
||||
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
|
||||
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
|
||||
// 唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
|
||||
// **自然结束**。可复现症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
|
||||
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
|
||||
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
|
||||
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
|
||||
// 中断里、被降级入队的那条也被计入(immediate 是单槽,降级是设计要求的路径)。
|
||||
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
|
||||
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
|
||||
//
|
||||
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
|
||||
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
|
||||
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = victim
|
||||
a.sched.nextRef() // running = victim
|
||||
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2)。
|
||||
victim.PreemptCount = 1
|
||||
victim.LastPreemptAt = time.Now()
|
||||
|
||||
evt, _ := textEvent("cli", "第二次终止")
|
||||
evt.Payload["interrupt"] = true
|
||||
if a.sched.requestPreempt(evt, LevelCritical) {
|
||||
t.Fatal("抢占冷却期内不得抢占(应入队)")
|
||||
}
|
||||
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
|
||||
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
|
||||
}
|
||||
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
|
||||
t.Fatalf("应恰好入队一条,实际 %d(>1 说明入队路径重复)", n)
|
||||
}
|
||||
|
||||
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
|
||||
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
|
||||
a.sched.rearmPending()
|
||||
if !a.sched.preemptGrantedFor() {
|
||||
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
|
||||
}
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
|
||||
}
|
||||
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
|
||||
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
|
||||
}
|
||||
// 重新求值不该把任务复制一份:队列必须空、immediate 恰好一条。
|
||||
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
|
||||
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一安全点前到达的两条同级中断:immediate 是单槽,第二条只能降级入队;
|
||||
// 它**没有**进入 immediate,因此不得计入 PreemptsByLevel,也不得被入队两次。
|
||||
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = victim
|
||||
a.sched.nextRef() // running = 排队任务(有效级 0,任何中断都能抢)
|
||||
|
||||
e1, _ := textEvent("cli", "irq-1")
|
||||
if !a.sched.requestPreempt(e1, LevelInteractive) {
|
||||
t.Fatal("第一条 L3 应抢占排队任务")
|
||||
}
|
||||
e2, _ := textEvent("cli", "irq-2")
|
||||
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
|
||||
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1,实际 %d)", got)
|
||||
}
|
||||
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
|
||||
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
|
||||
}
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
|
||||
}
|
||||
}
|
||||
|
||||
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
|
||||
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
|
||||
if a.sched == nil {
|
||||
t.Fatal("agent 应带调度器")
|
||||
}
|
||||
a.sched.mu.Lock()
|
||||
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
|
||||
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
|
||||
a.sched.stats.Suspended = 99 // 故意与抢占数不等
|
||||
a.sched.mu.Unlock()
|
||||
|
||||
got := a.schedulerStatus()
|
||||
if got.Preempted != 5 {
|
||||
t.Fatalf("Preempted 应为各级抢占数之和 5,实际 %d(拿 Suspended 顶替会得 99)", got.Preempted)
|
||||
}
|
||||
if got.Suspended != 99 {
|
||||
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
|
||||
}
|
||||
}
|
||||
|
||||
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
|
||||
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||||
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
|
||||
if !a.sched.enqueue(newInputTask(queuedEvt)) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
|
||||
a.sched.suspend(
|
||||
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
|
||||
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
|
||||
)
|
||||
|
||||
a.Stop()
|
||||
|
||||
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r == nil || r.Payload["skipped"] != true {
|
||||
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
|
||||
func TestBackpressure_CounterAndTransition(t *testing.T) {
|
||||
s := newScheduler(1)
|
||||
if !s.noteBackpressure() {
|
||||
t.Fatal("首次背压应报告「翻转」")
|
||||
}
|
||||
if s.noteBackpressure() {
|
||||
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
|
||||
}
|
||||
if s.stats.Backpressure != 2 {
|
||||
t.Fatalf("背压计数应为 2,实际 %d", s.stats.Backpressure)
|
||||
}
|
||||
s.clearBackpressure()
|
||||
if !s.noteBackpressure() {
|
||||
t.Fatal("队列恢复后再满应再次报告翻转")
|
||||
}
|
||||
}
|
||||
|
||||
// 集成:就绪队列满时 pumpInbox 必须计一次背压(Rejected 保持 0——背压不是丢弃)。
|
||||
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.sched.maxQueue = 1
|
||||
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
|
||||
a.pumpInbox()
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if len(snap.Queue) != 1 {
|
||||
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
|
||||
}
|
||||
if snap.Stats.Backpressure == 0 {
|
||||
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4:阻塞发送方 + 计数)")
|
||||
}
|
||||
if snap.Stats.Rejected != 0 {
|
||||
t.Fatalf("背压不是丢弃,Rejected 必须保持 0,实际 %d", snap.Stats.Rejected)
|
||||
}
|
||||
}
|
||||
@ -178,6 +178,10 @@ func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
|
||||
for i := 0; i < maxSteps; i++ {
|
||||
// 安全点:只在 step 之间检查让位。临界区(StepToolExec)不在此列,
|
||||
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
|
||||
//
|
||||
// 先「重新求值」再判让位:临界区(或抢占冷却期)内被拦成入队的中断,
|
||||
// 必须在这里重新武装——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
|
||||
a.sched.rearmPending()
|
||||
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
|
||||
return outcomeSuspended
|
||||
}
|
||||
|
||||
@ -411,7 +411,12 @@ func (b *Bridge) readLoop() {
|
||||
_ = ws.writePong()
|
||||
continue
|
||||
}
|
||||
// 超时或其他错误,退出
|
||||
// 超时或其他错误,退出。
|
||||
//
|
||||
// **必须记日志**:此前这里静默 return,设备断线的真因(读超时 / 对端
|
||||
// 关闭 / 帧错)在设备侧完全不可见,只能靠对端日志倒推。
|
||||
// 2 倍 ping 间隔内的读超时通常是“心跳没人回”——查服务端 writePong 是否真发出。
|
||||
log.Printf("[devicebridge] read loop exit (opcode=%#x, close=%v): %v", opcode, isClose, err)
|
||||
return
|
||||
}
|
||||
if isClose {
|
||||
|
||||
@ -257,7 +257,16 @@ func (s *Store) Add(name, content string) error {
|
||||
if category != "" {
|
||||
dirName = sanitize(category) + "/" + dirName
|
||||
}
|
||||
if err := checkSafeName(dirName); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(s.root, dirName)
|
||||
// 双保险:不得写到知识根之外(否则条目落在根外,重启 scanAll 扫不到,
|
||||
// 变成"内存有、盘上根外"的幽灵条目)
|
||||
rootClean := filepath.Clean(s.root)
|
||||
if dir != rootClean && !strings.HasPrefix(filepath.Clean(dir), rootClean+string(filepath.Separator)) {
|
||||
return fmt.Errorf("knowledge: 拒绝写入知识根之外的路径: %q", name)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create knowledge dir: %w", err)
|
||||
}
|
||||
@ -335,7 +344,18 @@ func (s *Store) Remove(name string) error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
id := sanitize(name)
|
||||
if err := checkSafeName(id); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(s.root, id)
|
||||
// 双保险:解析后的路径必须仍在知识根内。sanitize 不过滤 "..",
|
||||
// 少了这一步,Remove("..") 会 RemoveAll 掉整个数据目录
|
||||
// (实测把 <data> 连同 memory/documents/media 一起删掉),
|
||||
// 且 os.RemoveAll 对不存在的目标返回 nil ⇒ 工具层回报"已删除"。
|
||||
rootClean := filepath.Clean(s.root)
|
||||
if dir != rootClean && !strings.HasPrefix(filepath.Clean(dir), rootClean+string(filepath.Separator)) {
|
||||
return fmt.Errorf("knowledge: 拒绝删除知识根之外的路径: %q", name)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -541,6 +561,24 @@ func (s *Store) scanDir(category, dirName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// checkSafeName 拒绝会让路径逃出知识根的成分。
|
||||
//
|
||||
// sanitize 只做小写/去空格/换下划线,**不过滤 ".."**,所以
|
||||
// "../../x" 或 ".." 会被 filepath.Join 解析到知识根之外。
|
||||
// 这里在拼接之前挡掉:空段、"."、"..",以及以点开头的段
|
||||
// (后者会被 scanDir 当隐藏目录跳过,造成"写进去了却扫不回来")。
|
||||
func checkSafeName(name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("knowledge: 名称为空")
|
||||
}
|
||||
for _, seg := range strings.Split(name, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." || strings.HasPrefix(seg, ".") {
|
||||
return fmt.Errorf("knowledge: 名称含非法路径段 %q: %q", seg, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitize(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
109
internal/knowledge/pathescape_test.go
Normal file
109
internal/knowledge/pathescape_test.go
Normal file
@ -0,0 +1,109 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 知识名不得逃出知识根。
|
||||
//
|
||||
// 复现(修复前):Remove("..") 直接 os.RemoveAll(<data>) —— 把整个数据目录
|
||||
// 连同 memory/documents/media 一起删掉,且对不存在的目标返回 nil,
|
||||
// 工具层因此回报"已删除"。Add("../../x") 则把内容写到知识根外,
|
||||
// 重启 scanAll 扫不回来 ⇒ 幽灵条目。
|
||||
//
|
||||
// 该缺陷在 release/v1.0.x ~ v1.3.x 四条发布线上均存在。
|
||||
func TestNameCannotEscapeKnowledgeRoot(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "data", "knowledge")
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造出与生产同构的邻居:记忆/文档/媒体都在 <data> 下
|
||||
neighbors := []string{"memory", "documents", "media"}
|
||||
for _, n := range neighbors {
|
||||
if err := os.MkdirAll(filepath.Join(base, "data", n), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 放一个"数据"文件,确保邻居非空(空目录时 RemoveAll 会连父一起删)
|
||||
for _, n := range neighbors {
|
||||
p := filepath.Join(base, "data", n, "keep.db")
|
||||
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
s := NewStore(root)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
if err := s.Add("real", "正常知识"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"..", "../..", "../../..", "a/../../..", ".hidden", "x/.hidden"} {
|
||||
if err := s.Add(name, "越界内容"); err == nil {
|
||||
t.Errorf("Add(%q) 应被拒绝,实际成功了", name)
|
||||
}
|
||||
if err := s.Remove(name); err == nil {
|
||||
t.Errorf("Remove(%q) 应被拒绝,实际成功了", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 邻居必须完好
|
||||
for _, n := range neighbors {
|
||||
if _, err := os.Stat(filepath.Join(base, "data", n, "keep.db")); err != nil {
|
||||
t.Errorf("邻居数据 %s 被删了: %v", n, err)
|
||||
}
|
||||
}
|
||||
// 知识根本身与正常条目必须还在
|
||||
if _, err := os.Stat(filepath.Join(root, "real", "content.md")); err != nil {
|
||||
t.Errorf("正常知识被误删: %v", err)
|
||||
}
|
||||
// 根外不得留下任何东西
|
||||
if _, err := os.Stat(filepath.Join(base, "..")); err == nil {
|
||||
t.Log("(父目录存在属正常)")
|
||||
}
|
||||
for _, name := range []string{"real", ".hidden", "a"} {
|
||||
if _, err := os.Stat(filepath.Join(base, name)); err == nil {
|
||||
t.Errorf("根外残留了 %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反向验证:确认本分支修复前确实存在该缺陷(防止"修了个不存在的问题")。
|
||||
func TestVulnerableBaselineReproduces(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("需要真实执行破坏性路径")
|
||||
}
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "data", "knowledge")
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
neighbor := filepath.Join(base, "data", "memory")
|
||||
if err := os.MkdirAll(neighbor, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep := filepath.Join(neighbor, "keep.db")
|
||||
if err := os.WriteFile(keep, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewStore(root)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
// 修复后这里必然被拒;若真被删了,说明防护失效
|
||||
if err := s.Remove(".."); err == nil {
|
||||
t.Fatal("Remove(\"..\") 未被拒绝 —— 防护已失效")
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("数据被删: %v", err)
|
||||
}
|
||||
}
|
||||
@ -41,7 +41,11 @@ var (
|
||||
// 注入标志位、中断优先级、事件订阅、动态输出通道注销补进 Lua 侧
|
||||
// (此前只在 Go 侧存在而文档宣称“完全对齐”)。公开 Go SDK 接口
|
||||
// 零变更,故 SDK 保持 1.3.0。
|
||||
Version = "1.3.11"
|
||||
// 1.3.12:修 1.3.11 引入的两个真问题 —— ① 驻留子销毁后残留入站 inputch
|
||||
// child/<id>(改用纯函数名并 Unregister,覆盖 destroy/reclaim/StopResidents);
|
||||
// ② sdk.events.subscribe 用了从未注入的公共 Events(),且订阅生命周期
|
||||
// 管理会自死锁/use-after-close(改用内部 Subscribe + 独立 subsMu + Stop 取消)。
|
||||
Version = "1.3.13"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
agentEvents "gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
@ -42,7 +42,16 @@ type luaPlugin struct {
|
||||
stages map[sdk.Stage]*stageReg
|
||||
outputChs map[string]*outputChReg
|
||||
inputDefs map[string]sdk.ChannelDef
|
||||
mu sync.Mutex
|
||||
// subs 是本插件注册的事件订阅取消函数;Stop 时兜底取消,
|
||||
// 避免 L 已 Close 后残留回调被触发(use-after-close)。
|
||||
// 用独立的 subsMu 而非 mu:subscribe 会在 Lua 的 start 回调里被调,
|
||||
// 而 Start 正持着 mu —— 用 mu 就是不可重入的自死锁。
|
||||
subs []func()
|
||||
subsMu sync.Mutex
|
||||
// closed 在 Stop 里置位(持 mu);事件回调持 mu 后先查它,
|
||||
// 防止“回调已通过取消订阅检查、但等锁期间 L 被 Close”的竞态。
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newLuaPlugin(luaPath, name string) (*luaPlugin, error) {
|
||||
@ -791,20 +800,25 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
|
||||
return pushVal([]interface{}{})
|
||||
}))
|
||||
|
||||
// ---- sdk.events.*(只读事件订阅,与外部插件的 Events() 对齐)----
|
||||
// 回调在内核事件发布 goroutine 上执行,必须只做轻量转发(Lua 单状态 + 互斥锁);
|
||||
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数。
|
||||
// ---- sdk.events.*(只读事件订阅)----
|
||||
//
|
||||
// 用内部 SDK 的 Subscribe(内置插件用的是同一条路径);
|
||||
// 不用公共 SDK 的 Events()——那个 subscriber 在本内核里从未被注入
|
||||
// (SetEventSubscriber 无调用点),拿到的永远是 nil。
|
||||
//
|
||||
// 回调用内核事件发布 goroutine 上执行,必须只做轻量转发(Lua 单状态 + 互斥锁);
|
||||
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数,并在 Stop 时兜底取消
|
||||
// (否则插件停掉/重载后 L 已 Close,残留回调再触发就是 use-after-close)。
|
||||
evTbl := subTable("events")
|
||||
evTbl.RawSetString("subscribe", L.NewFunction(func(L *lua.LState) int {
|
||||
eventType := L.CheckString(1)
|
||||
fn := L.CheckFunction(2)
|
||||
sub := s.Events()
|
||||
if sub == nil {
|
||||
return pushErr(fmt.Errorf("events unavailable"))
|
||||
}
|
||||
unsub := sub.Subscribe(pubsdk.EventType(eventType), func(evt *pubsdk.Event) {
|
||||
unsub := s.Subscribe(agentEvents.EventType(eventType), func(evt *agentEvents.Event) {
|
||||
plg.mu.Lock()
|
||||
defer plg.mu.Unlock()
|
||||
if plg.closed {
|
||||
return
|
||||
}
|
||||
L2 := plg.L
|
||||
tbl := L2.NewTable()
|
||||
tbl.RawSetString("type", lua.LString(string(evt.Type)))
|
||||
@ -817,8 +831,11 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
|
||||
fmt.Printf("[lua-plugin/%s] event handler error: %v\n", plg.name, err)
|
||||
}
|
||||
})
|
||||
plg.subsMu.Lock()
|
||||
plg.subs = append(plg.subs, unsub)
|
||||
plg.subsMu.Unlock()
|
||||
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||
unsub()
|
||||
unsub() // 事件总线的取消订阅是幂等的(重复调用只会匹配不到)
|
||||
return 0
|
||||
}))
|
||||
L.Push(lua.LNil)
|
||||
@ -826,18 +843,31 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
|
||||
}))
|
||||
|
||||
// ---- sdk.plugin_mgr.*(插件管理,与外部插件的 PluginMgrAPI 对齐)----
|
||||
// PluginMgr 可能未装配(如部分单测的 SDK 构造),此时返回"不可用"而不是 panic。
|
||||
pmTbl := subTable("plugin_mgr")
|
||||
pmTbl.RawSetString("reload_one", L.NewFunction(func(L *lua.LState) int {
|
||||
if err := s.PluginMgr().ReloadOne(L.CheckString(1)); err != nil {
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushErr(fmt.Errorf("plugin manager unavailable"))
|
||||
}
|
||||
if err := pm.ReloadOne(L.CheckString(1)); err != nil {
|
||||
return pushErr(err)
|
||||
}
|
||||
return pushNil()
|
||||
}))
|
||||
pmTbl.RawSetString("list_loaded", L.NewFunction(func(L *lua.LState) int {
|
||||
return pushList(s.PluginMgr().ListLoadedPlugins())
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushList([]interface{}{})
|
||||
}
|
||||
return pushList(pm.ListLoadedPlugins())
|
||||
}))
|
||||
pmTbl.RawSetString("is_disabled", L.NewFunction(func(L *lua.LState) int {
|
||||
return pushVal(s.PluginMgr().IsPluginDisabled(L.CheckString(1)))
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushVal(false)
|
||||
}
|
||||
return pushVal(pm.IsPluginDisabled(L.CheckString(1)))
|
||||
}))
|
||||
}
|
||||
|
||||
@ -1207,8 +1237,21 @@ func (p *luaPlugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *luaPlugin) Stop() error {
|
||||
// ① 先取消事件订阅。**不持 p.mu**:Bus.Publish 持总线锁回调 handler,
|
||||
// 而 handler 要 p.mu;若此处持 p.mu 再取总线锁,就是锁序反转死锁。
|
||||
p.subsMu.Lock()
|
||||
subs := p.subs
|
||||
p.subs = nil
|
||||
p.subsMu.Unlock()
|
||||
for _, unsub := range subs {
|
||||
unsub()
|
||||
}
|
||||
|
||||
// ② 置 closed 并关 L。置位在持锁下完成:已进入但等锁的 event 回调
|
||||
// 拿到锁后会先看到 closed 而直接返回,不会碰已关的 L。
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.closed = true
|
||||
|
||||
if p.tbl != nil {
|
||||
fn := p.tbl.RawGetString("stop")
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
@ -597,3 +598,66 @@ return plugin
|
||||
t.Errorf("llm_text writeback: got %q, want %q", sc2.LLMText, "模型输出[尾部标记]")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaEventsSubscribeAndStopCleanup 覆盖 sdk.events.subscribe:
|
||||
// 1. 订阅真的能收到内核事件(走内部 SDK 的 Subscribe,不是永远为 nil 的公共 Events());
|
||||
// 2. Stop 会取消订阅,之后 Publish 不得再触碰已 Close 的 LState。
|
||||
func TestLuaEventsSubscribeAndStopCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"evlua","entry":"main.lua"}`), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
|
||||
local plugin = { name = "evlua" }
|
||||
|
||||
function plugin.start(sdk)
|
||||
_G.hits = 0
|
||||
local unsub, err = sdk.events.subscribe("agent_output", function(evt)
|
||||
_G.hits = _G.hits + 1
|
||||
_G.last_type = evt.type
|
||||
_G.last_source = evt.source
|
||||
end)
|
||||
_G.sub_err = err
|
||||
_G.unsub_type = type(unsub)
|
||||
end
|
||||
|
||||
function plugin.stop() end
|
||||
return plugin
|
||||
`), 0644)
|
||||
|
||||
plg, err := tryLoadLua(dir, "evlua", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tryLoadLua failed: %v", err)
|
||||
}
|
||||
lp := plg.(*luaPlugin)
|
||||
|
||||
bus := events.NewBus()
|
||||
reg := internalConfig.NewConfigRegistry("")
|
||||
sett := sdk.NewSettings("evlua", reg)
|
||||
s := sdk.New("evlua", sdk.SDKConfig{EventBus: bus, Settings: sett})
|
||||
|
||||
if err := plg.Start(s); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
|
||||
L := lp.L
|
||||
if errStr := L.GetGlobal("sub_err").String(); errStr != "nil" {
|
||||
t.Fatalf("subscribe returned error: %s", errStr)
|
||||
}
|
||||
if got := L.GetGlobal("unsub_type").String(); got != "function" {
|
||||
t.Fatalf("subscribe should return an unsubscribe function, got %s", got)
|
||||
}
|
||||
|
||||
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "test-src"})
|
||||
if hits := int(lua.LVAsNumber(L.GetGlobal("hits"))); hits != 1 {
|
||||
t.Fatalf("event handler hits = %d, want 1", hits)
|
||||
}
|
||||
if got := L.GetGlobal("last_source").String(); got != "test-src" {
|
||||
t.Fatalf("event source = %q, want test-src", got)
|
||||
}
|
||||
|
||||
// Stop 取消订阅 + 关 L;此后再 Publish 不得 panic / use-after-close。
|
||||
if err := plg.Stop(); err != nil {
|
||||
t.Fatalf("Stop failed: %v", err)
|
||||
}
|
||||
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "after-stop"})
|
||||
}
|
||||
|
||||
53
internal/plugins/remotedevice/ping_test.go
Normal file
53
internal/plugins/remotedevice/ping_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package remotedevice
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 心跳回包必须**真的发出去**:pong 只有两个字节,且设备空闲时没有任何别的写
|
||||
// 会顺带把 bufio 缓冲刷出去——`writePong` 一旦忘了 Flush,pong 就永远留在
|
||||
// 服务端缓冲里。
|
||||
//
|
||||
// 这就是「device channel 不稳定」的真因(实测):客户端每 30s 发一个 ping,
|
||||
// 服务端算好了 pong 却没发;客户端的读循环设的是 2 倍 ping 间隔(默认 60s)
|
||||
// 读超时,于是**每 60 秒准点断开一次**,重连后 outputch 被注销又注册,
|
||||
// 模型侧看到的就是工具/通道凭空消失又出现。
|
||||
//
|
||||
// 本用例只发一个 ping,随后**什么都不发**:pong 必须在无后续流量的情况下到达。
|
||||
func TestWSPingGetsPongWhileIdle(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
token := "test-token-ping"
|
||||
reg.SetAcceptToken(func(provided string) bool { return provided == token })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||
defer srv.Close()
|
||||
|
||||
cli := dialTestWS(t, srv.URL, token)
|
||||
defer cli.close()
|
||||
|
||||
// 先走完 hello + bind(服务端要先把设备登记进 conns,pong 才写得回来)。
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"ping-dev","name":"前端机","kind":"computer","caps":["cmd"]}}`))
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
cli.sendFrame(0x9, nil) // ping
|
||||
|
||||
if err := cli.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
payload, isClose, opcode, err := readFrame(cli.rw.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("2s 内没收到 pong(writePong 忘了 Flush?): %v", err)
|
||||
}
|
||||
if isClose {
|
||||
t.Fatal("连接被关闭,而不是回了 pong")
|
||||
}
|
||||
if opcode != 0xa {
|
||||
t.Fatalf("期望 pong(0xa),实际 opcode=%#x payload=%q", opcode, payload)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("pong 不该带负载,实际 %q", payload)
|
||||
}
|
||||
}
|
||||
@ -606,7 +606,13 @@ func writeFrame(w *bufio.Writer, opcode byte, payload []byte) error {
|
||||
}
|
||||
|
||||
func writePong(w *bufio.Writer) error {
|
||||
return writeFrameHeader(w, 0xa, 0)
|
||||
// 必须走 writeFrame(它 Flush)。
|
||||
//
|
||||
// 回归的 bug:这里原先是裸的 writeFrameHeader,**不 Flush**。设备空闲时
|
||||
// 没有任何别的写会顺带把 bufio 缓冲刷出去,于是 pong 永远留在服务端缓冲里,
|
||||
// 客户端等 2 倍 ping 间隔(默认 30s×2 = 60s)读超时断开、重连——
|
||||
// 实测表现就是「设备通道每 60 秒掉线一次」,连带着 outputch 反复注销/注册。
|
||||
return writeFrame(w, 0xa, nil)
|
||||
}
|
||||
|
||||
func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {
|
||||
|
||||
@ -68,7 +68,11 @@ type SchedulerStatus struct {
|
||||
Rejected uint64 `json:"rejected"`
|
||||
Suspended uint64 `json:"suspended"`
|
||||
Resumed uint64 `json:"resumed"`
|
||||
// Preempted = Σ PreemptsByLevel[1..4],即「真正抢占成功」的次数。
|
||||
// 它与 Suspended 不等价(受害者可能先自行结束),因此不是 Suspended 的别名。
|
||||
Preempted uint64 `json:"preempted"`
|
||||
// Backpressure 是就绪队列满、输入被挡回 channel 的次数(暂时不收,不是丢弃)。
|
||||
Backpressure uint64 `json:"backpressure"`
|
||||
}
|
||||
|
||||
// SchedulerTask 是任务的最小标识(不暴露帧内容)。
|
||||
|
||||
@ -38,10 +38,22 @@ func TestIsProtectedPath(t *testing.T) {
|
||||
|
||||
func TestArchiveBeforeWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 受保护路径
|
||||
target := "/etc/ArchiveBeforeWrite.test.tmp"
|
||||
os.WriteFile(target, []byte("original"), 0644)
|
||||
defer os.Remove(target)
|
||||
|
||||
// 受保护路径用**临时目录 + SetProtectedPaths 显式声明**,不写真实的 /etc。
|
||||
//
|
||||
// 为什么不能硬编码 /etc:CI(GitHub Actions runner)以非 root 运行,
|
||||
// os.WriteFile("/etc/...") 会 permission denied,而此处原先忽略了该错误
|
||||
// ⇒ 文件根本不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false
|
||||
// ⇒ 断言 "expected archive to happen" 失败。本地以 root 跑则通过,
|
||||
// 缺陷因此长期不可见(只有换到非 root 环境才暴露)。
|
||||
protected := t.TempDir()
|
||||
SetProtectedPaths([]string{protected})
|
||||
defer SetProtectedPaths(nil) // 恢复默认(/etc/),避免影响同包其它测试
|
||||
|
||||
target := filepath.Join(protected, "archive_before_write.tmp")
|
||||
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
|
||||
archived, err := ArchiveBeforeWrite(dir, target)
|
||||
if err != nil {
|
||||
@ -82,9 +94,17 @@ func TestArchiveBeforeWrite(t *testing.T) {
|
||||
|
||||
func TestRestoreFileFromBaseline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := "/etc/RestoreFileFromBaseline.test.tmp"
|
||||
os.WriteFile(target, []byte("v1"), 0644)
|
||||
defer os.Remove(target)
|
||||
|
||||
// 同 TestArchiveBeforeWrite:用临时目录声明受保护路径,不碰真实 /etc
|
||||
// (非 root 环境写 /etc 必然失败,会让断言在 CI 上假红)。
|
||||
protected := t.TempDir()
|
||||
SetProtectedPaths([]string{protected})
|
||||
defer SetProtectedPaths(nil)
|
||||
|
||||
target := filepath.Join(protected, "restore_from_baseline.tmp")
|
||||
if err := os.WriteFile(target, []byte("v1"), 0644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
|
||||
ArchiveBeforeWrite(dir, target)
|
||||
os.WriteFile(target, []byte("v2"), 0644)
|
||||
|
||||
Reference in New Issue
Block a user