Files
HomeAgent/internal/plugin/proc/plugin.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

559 lines
21 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 proc
import (
"context"
"encoding/json"
"fmt"
"log"
"path/filepath"
"sync"
"sync/atomic"
"time"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// Plugin 是 registry 可加载的子进程插件,与内置插件同构的启停接口。
//
// 生命周期:
//
// New() 创建(尚未 spawn)
// Start(core) spawn 子进程 → 握手(传共享段 fd)→ plugin.init → plugin.start
// (plugin.start 期间插件反向注册工具/阶段/通道)
// Stop() plugin.stop → 宽限期 → 必要时 Kill
// Close() 强制结束(registry 卸载/重载路径)
//
// **共享段不属于 Plugin**:它属于 Host,被全部子进程插件共享。
// 若每插件一段,「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下会退化成
// 副本模型,lost update 原样复现(§8.4)。
type Plugin struct {
name string
bin string
dir string
config map[string]interface{}
host *Host
proc *Process
handler *coreHandler
// env 追加到子进程环境变量(测试用;生产由 registry 按需设置)。
env []string
// onCrash 由 registry 注入,把进程退出喂给 plugin_health.recordCrash(§2.3)。
onCrash func(name string, err error)
// caps 是 manifest 声明的能力集(§3.8 权限梯度)。
caps *capabilitySet
// handshakeTimeout 是插件**自行声明**的建链超时(manifest 的
// handshake_timeout_sec)。<=0 ⇒ 用 DefaultHandshakeTimeout。
// 见 docs/zh/kernel-modularization-design.md §2.4.2。
handshakeTimeout time.Duration
// ownerID 是本插件在共享槽池里的身份(由 Host 分配)。
// 内核用它校验 arena.free 的归属,并在插件退出时回收残留槽。
ownerID uint32
stopOnce sync.Once
// cancelIgnored 是「取消后未停手」的**连续**次数(成功响应即清零)。
//
// 与 onCrash 的分工:onCrash 是进程真的死了;这里是进程活着但
// 对取消无响应。两者都指向"该重启",但成因不同,日志要能区分。
cancelIgnoredMu sync.Mutex
cancelIgnored int
// stopping 标记「本次退出是内核主动发起的」,用于压掉 onCrash。
//
// 必要性:Stop() 宽限期超时与 Close() 都走 Process.Kill(),
// 而 Kill 产生的 `signal: killed` 是非 nil 的 waitErr——若不区分,
// 重载/禁用/卸载这些**内核自己发起**的停止会被 handleExit 当成崩溃上报,
// 触发一轮多余的自动重启(重载路径下等于把刚装好的插件又推倒一次)。
stopping atomic.Bool
}
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// capabilities 来自 manifest 的 capabilities 字段;为空时不限制(存量插件向后兼容)。
//
// 建链超时用内核默认值。需要按插件声明覆盖时用 NewWithTimeout。
func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), capabilities ...string) *Plugin {
return NewWithTimeout(name, bin, dir, config, host, onCrash, 0, capabilities...)
}
// NewWithTimeout 同 New,但可指定**建链(握手)超时**。
//
// timeout <= 0 ⇒ 用 DefaultHandshakeTimeout(与 New 行为一致)。
//
// ## 为何单独一个构造函数而不是改 New 的签名
//
// `New` 末尾是变参 `capabilities ...string`。中间插一个 duration 参数
// 会让所有调用点错位(值会静默地用错),而编译器**不会**报错
// (变参位置可吞类型兼容的实参)——那是本仓最怕的"静默错误"。
// 加一个新构造函数既保持旧签名不变,又把新能力显式化。
func NewWithTimeout(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), timeout time.Duration, capabilities ...string) *Plugin {
return &Plugin{
name: name,
bin: bin,
dir: dir,
config: config,
host: host,
onCrash: onCrash,
caps: newCapabilitySet(capabilities),
handshakeTimeout: timeout,
}
}
// Name 实现 sdk.Plugin。
func (p *Plugin) Name() string { return p.name }
// PID 返回子进程号;未启动或已退出返回 0。
// 供 pluginmgr 呈现「插件实际在跑哪个进程」。
func (p *Plugin) PID() int {
if p.proc == nil {
return 0
}
if !p.Alive() {
return 0
}
return p.proc.PID()
}
// Alive 报告子进程是否仍存活。
func (p *Plugin) Alive() bool {
if p.proc == nil {
return false
}
select {
case <-p.proc.Exited():
return false
default:
return true
}
}
// Start 启动子进程并完成注册。
//
// core 是内核为该插件构建的能力面(internal/sdk.PluginSDK 天然满足 CoreSDK)。
func (p *Plugin) Start(core CoreSDK) error {
if p.host == nil {
return fmt.Errorf("proc: %s 缺少共享段 Host", p.name)
}
// 领一个槽池身份;插件退出时用它回收残留槽。
p.ownerID = p.host.NextOwnerID()
// 新进程 = 新状态:重启后取消无响应的计数必须归零。
//
// 不归零的后果:插件可能已经换成一个健康的新进程,而计数器还带着
// 旧进程的账——下一次偶发的一次取消无响应就会把刚重启好的插件再踢一次,
// 形成"越重启越容易被重启"的恶性循环。
p.cancelIgnoredMu.Lock()
p.cancelIgnored = 0
p.cancelIgnoredMu.Unlock()
p.handler = &coreHandler{
sdk: core,
name: p.name,
host: p.host,
owner: p.ownerID,
locks: p.host.locks,
evtRing: p.host.evtSubscriber,
caps: p.caps,
}
// 反向调用闭包:注册回调时捕获,运行期经 RPC 打到插件进程。
p.handler.invokeTool = p.invokeTool
p.handler.invokeCleaner = p.invokeCleaner
p.handler.invokeStageFn = p.invokeStage
p.handler.invokeOutput = p.invokeOutput
// ★ 显式把插件目录传给**插件进程**(随机化之前的隐式 cwd 不可靠:
// 插件自己 chdir 后就失效,而 rawfile 的路径解析依赖它)。
pluginEnv := append([]string{}, p.env...)
pluginEnv = append(pluginEnv,
"HA_PLUGIN_DIR="+p.dir,
"HA_PLUGIN_RAWFILE_DIR="+filepath.Join(p.dir, "rawfile"),
"HA_PLUGIN_WORK_DIR="+filepath.Join(p.dir, "work"),
)
proc, err := Spawn(p.name, p.bin, Options{
Dir: p.dir,
// 插件自行声明的建链超时(<=0 时 Spawn 内部回退到默认值)。
HandshakeTimeout: p.handshakeTimeout,
// 共享段的传递机制按平台不同(shmpass_*.go):
// Unix 经 ExtraFiles 传继承 fd(3=统一共享内存区域 SuperBlock+StageContext+EvtRing,
// 4=事件通知 eventfd);Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。
// 权威定义在 shmpass_unix.go 的 procExtraFilesForShm,修改时三处必须同步。
Env: append(pluginEnv, p.host.procEnvForShm()...),
ExtraFiles: p.host.procExtraFilesForShm(),
ShmSize: p.host.shmSize,
EvtRingSize: evtTotalSize,
Handler: p.handler.Handle,
OnExit: p.handleExit,
Supervisor: p.host.Supervisor(),
})
if err != nil {
return err
}
p.proc = proc
// 让 handler 能按 CallID 查在途调用(arena.alloc 需要把块登记到调用名下)。
p.handler.process = proc
// 取消超时时的两件收尾:强制回收块 + 上报插件失去响应。
proc.SetReclaimBlocks(p.host.ReclaimRefs)
proc.OnCancelIgnored(func(callID uint64, grace time.Duration) {
p.noteCancelIgnored(callID, grace)
})
proc.OnCancelHonored(func(callID uint64) {
// 插件收到取消并停手了:连续计数清零(判据是"连续"而非"累计")。
p.cancelIgnoredMu.Lock()
if p.cancelIgnored > 0 {
p.cancelIgnored = 0
}
p.cancelIgnoredMu.Unlock()
})
// plugin.init:构造插件实例
if _, err := proc.Call(MethodPluginInit, PluginInitParams{
Name: p.name,
Config: p.config,
}); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.init 失败: %w", p.name, err)
}
// plugin.start:插件在此期间反向注册工具/阶段/通道
if _, err := proc.Call(MethodPluginStart, nil); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.start 失败: %w", p.name, err)
}
return nil
}
// Stop 优雅停止(实现 sdk.Plugin)。
func (p *Plugin) Stop() error {
p.stopping.Store(true)
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Stop()
}
})
return err
}
// Close 强制结束子进程。
//
// **这里是真 kill + wait**——对比 cabi 路径的 Close 只做 dlclose,
// 而 dlclose 对 Go c-shared 是 no-op(§1.1,热重载静默失效的根因)。
func (p *Plugin) Close() error {
p.stopping.Store(true)
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Kill()
}
})
return err
}
// handleExit 在子进程退出时把信号喂给 plugin_health(§2.3 逻辑复用),
// 并释放该插件可能持有的 stage 锁。
//
// 后者是"锁仲裁回内核"的自愈价值:持锁者死亡不会导致全局死锁,
// 无需 robust pthread_mutex(实验 9)。
func (p *Plugin) handleExit(name string, err error) {
if p.host != nil {
if p.host.ForceReleaseLock(name) {
log.Printf("[proc] %s exited, kernel released its stage locks", name)
}
// 回收该插件未归还的共享槽:崩溃的插件无法自己归还,
// 不回收会让槽池慢慢耗尽,最终所有共享内存调用退化成内联 RPC。
if n := p.host.ReclaimOwner(p.ownerID); n > 0 {
log.Printf("[proc] %s exited, reclaimed %d leftover shared slot(s)", name, n)
}
}
// 内核主动停止(Stop/Close,含宽限期超时后的 Kill)不算崩溃:
// 否则重载/禁用/卸载都会误触发自动重启。
if p.stopping.Load() {
return
}
if err != nil && p.onCrash != nil {
p.onCrash(name, err)
}
}
// ---- 内核 → 插件的反向调用 ----
// invokeTool 在插件进程内执行工具。
//
// 按 **funccall 模型**:内核是 caller,为每次调用**标定一块内存帧**
// (参数段 + 结果预算段)交给插件(callee)。参数永远写在帧里,不再有
// “小 payload 走内联”的按大小分支。
//
// 结果超出预算时插件才向内核申请扩容块,并在引用上打
// sharedRefFlagExpand,内核据此单独归还。
//
// 控制面仍是 RPC(请求 ID 关联、ctx 取消、崩溃唤醒都由 Process 承载)。
func (p *Plugin) invokeTool(name string, args map[string]interface{}) (interface{}, error) {
// 从 args 旁路取出内核放进来的执行上下文(见 core.executeCtxArgKey)。
// 取不到就是 Background —— 直连调用、单测、非 agent 路径都走这里,行为不变。
return p.invokeToolCtx(execCtxFromArgs(args), name, stripExecCtx(args))
}
// invokeToolCtx 在插件进程内执行工具,并把 ctx 传给 RPC。
//
// ctx 取消时 CallContext 会:① 让调用方立即返回,② 向插件发 call.cancel
// 通知它别再跑了。**插件作者一行不需要改**——handler 签名不变,取消由
// 模板运行时接住并让阻塞中的 handler 拿到 context.Canceled。
func (p *Plugin) invokeToolCtx(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
arena := p.host.Arena()
gen := p.host.Generation()
argJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 参数序列化失败: %w", p.name, name, err)
}
// 内核标定调用帧:参数段 + 结果预算段。
frame, err := arena.Alloc(OwnerHost, len(argJSON)+toolResultBudget, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 分配调用帧失败: %w", p.name, name, err)
}
defer func() { _ = arena.Free(OwnerHost, frame) }()
area, err := arena.Read(frame, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 读取调用帧失败: %w", p.name, name, err)
}
copy(area[:len(argJSON)], argJSON)
raw, err := p.proc.CallContext(ctx, MethodToolInvoke, ToolInvokeParams{
Name: name,
Frame: frame,
ArgsLen: uint32(len(argJSON)),
})
if err != nil {
return nil, err
}
var res ToolInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 应答解析失败: %w", p.name, name, err)
}
if res.ResultRef.IsZero() {
return nil, fmt.Errorf("proc: %s 工具 %s 未返回结果引用", p.name, name)
}
// 插件申请了扩容块:内核负责归还。
// ★ 必须用**插件自己的 ownerID**:块是插件经 arena.alloc 申请的,
// 块头 owner 记的是 p.ownerID。传 OwnerHost 会撞上 Free 的归属校验,
// 且错误若被 `_ =` 吞掉就是永久泄漏(2026-10-25 线上 4MB arena 被打满的根因)。
if res.ResultRef.Flags&sharedRefFlagExpand != 0 {
defer func() { p.freeExpand("tool", name, res.ResultRef) }()
}
data, err := arena.Read(res.ResultRef, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 读取共享结果失败: %w", p.name, name, err)
}
var out interface{}
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 解析共享结果失败: %w", p.name, name, err)
}
return out, nil
}
// invokeCleaner 在插件进程内执行工具或通道注册时提供的 Cleaner 函数。
//
// 数据面参数(输入槽 / 预分配响应槽)由内核在 params 里给出,
// 插件只负责读输入、写结果,不做任何分配。
func (p *Plugin) invokeCleaner(params CleanerInvokeParams) (CleanerInvokeResult, error) {
if p.proc == nil {
return CleanerInvokeResult{}, ErrProcessExited
}
raw, err := p.proc.Call(MethodCleanerInvoke, params)
if err != nil {
return CleanerInvokeResult{}, err
}
var res CleanerInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return CleanerInvokeResult{}, fmt.Errorf("proc: %s %s Cleaner %s 应答解析失败: %w", p.name, params.Scope, params.Name, err)
}
return res, nil
}
// cancelIgnoredLimit 是「取消无响应」的容忍次数。
//
// 判据的来源(用户 2026-10-25 裁定):长任务超时是**正常的**(时间给少了),
// 不能作为插件不稳定的依据;真正说明插件卡死的是**喊停之后还不停**。
// 因此只对"取消超时"计数,连续超过这个次数才重启。
//
// 取 2:一次可能是收尾特别慢(磁盘抖动、大文件落盘),两次就基本可以
// 断定它不会自己回来了。注意这里是"连续"——成功响应一次取消就清零。
const cancelIgnoredLimit = 2
// noteCancelIgnored 记录一次"取消后未停手",达到阈值则重启插件。
//
// 为什么重启而不是继续容忍:一个对取消无响应的插件进程会持续占着
// 连接/子进程/文件句柄,且每次调用都要等满 cancelGrace 才收尾——
// 后续所有调用都会被拖慢。重启是唯一能真正清掉这些资源的手段。
func (p *Plugin) noteCancelIgnored(callID uint64, grace time.Duration) {
p.cancelIgnoredMu.Lock()
p.cancelIgnored++
n := p.cancelIgnored
p.cancelIgnoredMu.Unlock()
if n < cancelIgnoredLimit {
log.Printf("[proc] %s did not stop after cancel (%d/%d, call %d): not restarting yet, still observing",
p.name, n, cancelIgnoredLimit, callID)
return
}
log.Printf("[proc] %s failed to stop within %v after cancel %d times in a row, judged crashed/unstable -- restarting proactively",
p.name, n, grace)
// 走既有的崩溃重启链(含退避、限流、禁用手工介入检查):
// 不在这里自己 Kill+Reload,否则会绕开那些保护。
if p.onCrash != nil {
p.onCrash(p.name, fmt.Errorf("unresponsive %d times in a row after cancel (last call %d, grace %v)",
n, callID, grace))
}
}
// freeExpand 让内核代为回收插件申请的扩容块,失败时**说出来**。
//
// owner 必须是插件自己的 ownerID(见调用点注释);失败必须留日志——
// 之前是 `_ = arena.Free(...)`,把原因直接丢弃,线上排查代价极高。
func (p *Plugin) freeExpand(what, name string, ref SharedRef) {
if err := p.host.Arena().Free(p.ownerID, ref); err != nil {
log.Printf("[proc] %s %s %s expansion block return failed (arena will leak slowly): %v",
p.name, what, name, err)
}
}
func (p *Plugin) invokeStage(ctx context.Context, stage string, seq uint64) error {
if p.proc == nil {
return ErrProcessExited
}
raw, err := p.proc.CallContext(ctx, MethodStageInvoke, StageInvokeParams{
Stage: stage,
Seq: seq,
})
if err != nil {
return err
}
var res StageInvokeResult
if len(raw) > 0 {
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("proc: %s stage %s 应答解析失败: %w", p.name, stage, err)
}
}
if res.DirtyFields > 0 {
log.Printf("[proc] %s stage %s modified %d field(s)", p.name, stage, res.DirtyFields)
}
return nil
}
// invokeOutput 经插件输出通道发送,**同步等待真实结果**。
//
// 这是 §9.4 的根治:C ABI 下 cgo 不可嵌套,只能异步 fire-and-forget,
// 导致 output_send 永远返回 {status:queued} + err=nil,模型永远以为发送成功
// (现网 7 天内 2 次消息实际发不出)。进程模型下 RPC 天然可等应答。
func (p *Plugin) invokeOutput(channel string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
argJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("proc: %s 输出通道 %s 参数序列化失败: %w", p.name, channel, err)
}
// 与工具调用同一个 funccall 帧模型(§13.6):payload 全在共享内存,
// RPC 只传偏移描述符。输出 payload 在真机上可能含图片/文件描述等大字段,
// 内联会撑爆 stdin/stdout 管道。
//
// 帧尾不预留结果区:output 的应答很小("ok" 或 status map),直接走
// RPC 应答字段即可,不像 tool.invoke 那样需要几十 KB 的结果预算。
var params OutputInvokeParams
params.Channel = channel
if len(argJSON) > 0 {
arena := p.host.Arena()
gen := p.host.Generation()
frame, err := arena.Alloc(OwnerHost, len(argJSON), gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 输出通道 %s 分配调用帧失败: %w", p.name, channel, err)
}
defer func() { _ = arena.Free(OwnerHost, frame) }()
area, err := arena.Read(frame, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 输出通道 %s 读取调用帧失败: %w", p.name, channel, err)
}
copy(area[:len(argJSON)], argJSON)
params.Frame = frame
params.ArgsLen = uint32(len(argJSON))
}
raw, err := p.proc.Call(MethodOutputInvoke, params)
if err != nil {
return nil, err // 真实失败上报,模型可感知并重试
}
if len(raw) == 0 {
return map[string]interface{}{"status": "ok"}, nil
}
// 结果可能在共享内存里(插件把大结果写回帧结果区)。
// 先按结构化应答解析;ResultRef 为零则回退到内联字段。
var outRes OutputInvokeResult
if jerr := json.Unmarshal(raw, &outRes); jerr == nil && !outRes.ResultRef.IsZero() {
arena := p.host.Arena()
gen := p.host.Generation()
// 插件申请了扩容块:内核负责归还(与 invokeTool 一致)。
if outRes.ResultRef.Flags&sharedRefFlagExpand != 0 {
defer func() { p.freeExpand("output", channel, outRes.ResultRef) }()
}
data, err := arena.Read(outRes.ResultRef, gen)
if err != nil {
return nil, fmt.Errorf("proc: %s 输出通道 %s 读取共享结果失败: %w", p.name, channel, err)
}
var out interface{}
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("proc: %s 输出通道 %s 解析共享结果失败: %w", p.name, channel, err)
}
return out, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err == nil {
if _, ok := res["status"]; !ok {
res["status"] = "sent"
}
return res, nil
}
// 插件返回的是标量(如 "ok")——**原样透传,不要伪造 status**。
//
// 为什么必须透传:核心 output.go 会把 map 结果格式化成富回执
// ("已通过 [qq] 通道发送: map[status:sent]"),模型看到"发送成功 +
// 详情"会把这一步当成"上一步完成、继续下一步"的信号,形成
// output_send 回声循环。插件(如 qq)刻意返回极简的 "ok" 就是为了
// 掐断这个信号;早期实现在这里把非 map 响应替换成 {status:sent},
// 等于把它又变回富回执——**只改插件永远修不掉这个循环**。
var scalar interface{}
if err := json.Unmarshal(raw, &scalar); err != nil {
return map[string]interface{}{"status": "ok"}, nil
}
return scalar, nil
}
// 编译期确认 Plugin 具备 registry 需要的启停形状。
var _ interface {
Name() string
Stop() error
Close() error
} = (*Plugin)(nil)
// 引用一下公开 SDK,确保本文件的类型假设与它同版本。
var _ = pubsdk.StageScopeGlobal