feat(status+webui): 运行态图形化 —— 排队/四级中断队列/中断栈/驻留子/通道拓扑

需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断
排队与中断栈、驻留子 agent 数量;这些要向**内部 SDK 暴露接口**,供 WebUI 等应用
展示;通道划分也要能画出来。

## 一、内核状态面(internal/sdk,内部 SDK,不受公开 SDK 冻结约束)

* SchedulerStatus 补:
  - interrupt_queues[5]:**四级中断队列各自的深度**(下标即级别 1..4,下标 0 恒 0,
    这样 level 能直接当数组下标用)。此前只有 pending_interrupts 总数,
    看不出"堵在 L1 还是 L4"——四级是抢占优先级,堵在哪级是完全不同的运行状态。
  - immediate:刚抢占成功、下一个安全点立即运行的那个中断(此前完全不可见)。
  - suspend_frames:中断栈的帧(栈底→栈顶,只给任务标识),depth 之外还能看出
    "谁被谁打断"。
  - interrupts_by_level / preempts_by_level:各级累计登记数与抢占成功数。
* KernelStatus 补 residents(驻留子运行时视图:状态/轮次/上下文已满/输入通道/允许输出)。
  刻意**不带**每个驻留子的 inputch 登记明细——状态面会被反复轮询,明细会让
  每次 /status 背上几十 KB;只给表大小,要明细走专门接口。
* ChannelInfo 补 direction(in/out/io)、description、tools、output_caps、caps_text。
  此前 collectKernelStatus 只透传 Name/Type,把描述/工具/能力**全丢了**,
  前端只能画出一排光秃秃的名字。

## 二、WebUI

* 新增只读 `/api/v1/runtime`:只回运行态三件事(scheduler/residents/channels),
  实测 **1.0KB**(/kernel 是 30KB 级)——所以能 3 秒轮询做"实时"感,
  而不必反复拉全量状态。
* 首页新增「运行态」面板(纯 CSS + 内联 SVG,前端仍无构建链):
  - 四个数字块:排队任务 / 待处理中断 / 中断栈(深度/上限) / 驻留子 Agent,带占比条;
  - 四级中断队列条形图:每级"深度 · 登记/抢占",四级语义**照抄内核**
    (L4 内核独占 / L3 交互 / L2 消息 / L1 后台),不自己起名字;
  - 中断栈层叠图(栈顶在上)+ 立即运行项;
  - 通道拓扑:输入通道 → 内核 → 输出通道,双向通道两侧都出现,能力以胶囊标签显示。
* 3 秒轮询只在总览页可见时才发请求;切回总览时 renderAll 会立刻补一次。

## 验证

* 新增 TestSchedulerStatusExposesLevelsAndStack(四级队列/立即项/栈帧/各级计数映射,
  并断言"未使用的级别必须为 0"与"下标 0 恒 0")、TestChannelInfoCarriesTopology、
  TestRuntimeEndpoint(形状 + 不携带 tools/plugins + 无状态源时 503)。
* go build / vet / agent+core+sdk+plugin+webui 全量测试绿。
* 真实浏览器实测(CDP 驱动,注入运行态样本走真实渲染路径):
  数字块 [3, 5, 2/4, 2];四级条 L4=1/L3=2/L2=1/L1=1 与数据一致;
  栈帧按"栈底→栈顶"渲染且标出栈顶;通道左右分列、io 通道两侧都出现。
This commit is contained in:
HomeAgent Agent
2026-09-14 09:05:14 +08:00
parent c0e9dc1818
commit 1ce3a917a5
9 changed files with 685 additions and 6 deletions

View File

@ -279,6 +279,8 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
PendingInterrupts: len(snap.PendingInterrupts),
SuspendStack: len(snap.SuspendStack),
MaxSuspendDepth: snap.MaxInterruptFrames,
InterruptsByLevel: snap.Stats.InterruptsByLevel,
PreemptsByLevel: snap.Stats.PreemptsByLevel,
Enqueued: snap.Stats.Enqueued,
Executed: snap.Stats.Executed,
Rejected: snap.Stats.Rejected,
@ -291,6 +293,26 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
ID: snap.Running.ID, Level: int(snap.Running.Level), Kind: snap.Running.Kind.String(),
}
}
if snap.Immediate != nil {
out.Immediate = &sdk.SchedulerTask{
ID: snap.Immediate.ID, Level: int(snap.Immediate.Level), Kind: snap.Immediate.Kind.String(),
}
}
// 四级队列深度下标即级别1..4),下标 0 留 0。
for lv := LevelBackground; lv <= LevelCritical; lv++ {
out.InterruptQueues[lv] = len(snap.InterruptQueues[lv])
}
// 中断栈帧:栈底 → 栈顶(谁先被压进去、谁又打断了它)。
for _, f := range snap.SuspendStack {
if f == nil || f.Task == nil {
continue
}
out.SuspendFrames = append(out.SuspendFrames, sdk.SchedulerFrame{
Task: sdk.SchedulerTask{
ID: f.Task.ID, Level: int(f.Task.Level), Kind: f.Task.Kind.String(),
},
})
}
return out
}

View File

@ -165,3 +165,96 @@ func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
t.Fatalf("Executed=%d期望 1", snap.Stats.Executed)
}
}
// TestSchedulerStatusExposesLevelsAndStack 钉住调度器状态面**按级别可读**
// 四级队列深度、各级累计计数、立即抢占项、中断栈帧。
//
// 起因:此前 SchedulerStatus 只给 totalpending_interrupts / suspend_stack
// 看不出"堵在 L1 还是 L4"、"栈里压的是谁"。WebUI 想画运行态就无数据可用。
//
// 这个用例直接摆好调度器内部状态、只验**映射**(同包白盒):调度行为本身
// 由 scheduler_e2e_test / scheduler_critical_test 覆盖,这里不该重复它们的时序。
func TestSchedulerStatusExposesLevelsAndStack(t *testing.T) {
a := New(AgentConfig{ID: "rt", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
if a.sched == nil {
t.Fatal("agent 应带调度器")
}
a.sched.mu.Lock()
a.sched.queue = append(a.sched.queue, &Task{ID: 1, Class: TaskQueued, Kind: TaskKindInput})
a.sched.interruptQueues[LevelCritical] = append(a.sched.interruptQueues[LevelCritical],
&Task{ID: 2, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput},
&Task{ID: 3, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput})
a.sched.interruptQueues[LevelBackground] = append(a.sched.interruptQueues[LevelBackground],
&Task{ID: 4, Class: TaskInterrupt, Level: LevelBackground, Kind: TaskKindSelf})
a.sched.immediate = &Task{ID: 5, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput}
a.sched.suspendStack = append(a.sched.suspendStack, &suspendedTask{
Task: &Task{ID: 6, Class: TaskInterrupt, Level: LevelInteractive, Kind: TaskKindInput},
Frame: &TaskFrame{},
})
a.sched.stats.InterruptsByLevel[LevelCritical] = 7
a.sched.stats.PreemptsByLevel[LevelCritical] = 3
a.sched.mu.Unlock()
got := a.schedulerStatus()
if got.ReadyQueueDepth != 1 {
t.Fatalf("ready_queue_depth 应为 1实际 %d", got.ReadyQueueDepth)
}
// 按级别的队列深度:下标即级别,下标 0 恒为 0
if got.InterruptQueues[LevelCritical] != 2 || got.InterruptQueues[LevelBackground] != 1 {
t.Fatalf("按级别队列深度不对:%v", got.InterruptQueues)
}
if got.InterruptQueues[0] != 0 {
t.Fatalf("下标 0无级别必须恒为 0实际 %v", got.InterruptQueues)
}
// pending = 两条队列 + immediate
if got.PendingInterrupts != 4 {
t.Fatalf("pending_interrupts 应为 4实际 %d", got.PendingInterrupts)
}
// 立即抢占项要能单独看到
if got.Immediate == nil || got.Immediate.ID != 5 || got.Immediate.Level != int(LevelCritical) {
t.Fatalf("immediate 未正确映射:%+v", got.Immediate)
}
// 中断栈帧(栈底→栈顶)要带任务标识
if len(got.SuspendFrames) != 1 || got.SuspendFrames[0].Task.ID != 6 ||
got.SuspendFrames[0].Task.Level != int(LevelInteractive) {
t.Fatalf("中断栈帧未正确映射:%+v", got.SuspendFrames)
}
// 累计计数按级别透传
if got.InterruptsByLevel[LevelCritical] != 7 || got.PreemptsByLevel[LevelCritical] != 3 {
t.Fatalf("各级计数未透传interrupts=%v preempts=%v", got.InterruptsByLevel, got.PreemptsByLevel)
}
// 未使用的级别必须是 0不能把别的级别串进来
if got.InterruptQueues[LevelMessage] != 0 || got.InterruptQueues[LevelInteractive] != 0 {
t.Fatalf("未使用的级别应为 0%v", got.InterruptQueues)
}
}
// TestChannelInfoCarriesTopology 钉住通道状态面不再丢信息:
// 方向、描述、工具名、输出能力都要能被前端画出来。
func TestChannelInfoCarriesTopology(t *testing.T) {
ch := channelInfoFromIO(agentIO.ChannelInfo{
Name: "webui",
Type: agentIO.DeviceOutput,
Description: "Web 控制台",
Tools: []agentIO.ToolDef{{Name: "output_send"}},
OutputCaps: agentIO.CapText | agentIO.CapImage,
})
if ch.Direction != "out" {
t.Fatalf("方向应为 out实际 %q", ch.Direction)
}
if ch.Description != "Web 控制台" {
t.Fatalf("描述丢失:%q", ch.Description)
}
if len(ch.Tools) != 1 || ch.Tools[0] != "output_send" {
t.Fatalf("工具名丢失:%v", ch.Tools)
}
if ch.OutputCaps != int(agentIO.CapText|agentIO.CapImage) || ch.CapsText == "" {
t.Fatalf("输出能力丢失caps=%d text=%q", ch.OutputCaps, ch.CapsText)
}
if d := channelInfoFromIO(agentIO.ChannelInfo{Name: "mic", Type: agentIO.DeviceInput}).Direction; d != "in" {
t.Fatalf("输入通道方向应为 in实际 %q", d)
}
if d := channelInfoFromIO(agentIO.ChannelInfo{Name: "both", Type: agentIO.DeviceIO}).Direction; d != "io" {
t.Fatalf("双向通道方向应为 io实际 %q", d)
}
}

View File

@ -25,6 +25,9 @@ type StatusProvider interface {
type KernelStatus = sdk.KernelStatus
type PluginInfo = sdk.PluginInfo
type ChannelInfo = sdk.ChannelInfo
// ResidentStatus 是驻留式子 agent 的运行时视图(见 internal/sdk/status.go
type ResidentStatus = sdk.ResidentStatus
type MemoryStatus = sdk.MemoryStatus
type KnowledgeStatus = sdk.KnowledgeStatus
type DocumentStatus = sdk.DocumentStatus
@ -37,10 +40,32 @@ type TrackerStatus = sdk.TrackerStatus
type BuildStatus = sdk.BuildStatus
func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo {
tools := make([]string, 0, len(ch.Tools))
for _, t := range ch.Tools {
tools = append(tools, t.Name)
}
return ChannelInfo{
Name: ch.Name,
Type: fmt.Sprintf("%d", ch.Type),
Ready: true,
Name: ch.Name,
Type: fmt.Sprintf("%d", ch.Type),
Direction: channelDirection(ch.Type),
Ready: true,
Description: ch.Description,
Tools: tools,
OutputCaps: int(ch.OutputCaps),
CapsText: ch.OutputCaps.String(),
}
}
// channelDirection 把 DeviceType 翻成可读方向(前端画拓扑用)。
// 未知取值落到 "io":宁可当作双向,也不要把它画成只进或只出。
func channelDirection(t agentIO.DeviceType) string {
switch t {
case agentIO.DeviceInput:
return "in"
case agentIO.DeviceOutput:
return "out"
default:
return "io"
}
}
@ -221,6 +246,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
)
ks.ONNX = a.onnxStatus()
ks.Scheduler = a.schedulerStatus()
ks.Residents = residentStatuses(a.Residents())
return ks
}
@ -254,3 +280,28 @@ func (a *Agent) onnxStatus() sdk.ONNXStatus {
var _ StatusProvider = (*Agent)(nil)
var _ sdk.StatusAPI = (*Agent)(nil)
// residentStatuses 把内核的驻留子视图映射成 SDK DTO。
//
// 刻意**不带** ResidentInfo.Table那是每个驻留子的 inputch 登记明细):状态面
// 是给所有应用轮询的,把它塞进来会让每次 /status 都背上几十 KB。
// 只给表的大小InputChTable要看明细走专门的接口。
func residentStatuses(list []ResidentInfo) []ResidentStatus {
out := make([]ResidentStatus, 0, len(list))
for _, r := range list {
st := ResidentStatus{
ID: r.ID,
State: r.State,
Rounds: r.Rounds,
ContextFull: r.ContextFull,
InputChs: r.InputChs,
AllowedOutputs: r.AllowedOutputs,
InputChTable: r.TableSize,
}
if !r.CreatedAt.IsZero() {
st.CreatedAt = r.CreatedAt.Format(time.RFC3339)
}
out = append(out, st)
}
return out
}