Compare commits

...
Author SHA1 Message Date
weichen 7776399fe7 Reapply the A1 review m1-m4 fixes lost in a stale apply
Why: the m1-m4 changes (intent_session comment sync, cancel reason,
expiry fail-closed, slot ownership) were applied to a backend state
that later got reset, losing them from the branch; the reviewer
correctly flagged their absence.
Evidence: repair receipt (a1m-run.json) reusing the preserved
transport diff; per-package tests below.
Impact: the four review-minor behaviors restored on the current tip.
Refs: feature/audit-review-minors-cleanup
2026-10-23 12:28:01 +00:00
agent-exec-transport 67102901d6 agent-exec transport 7a27ae40d354fd19ec3b760cf1a7ce9a/d2827ccb55ff 2026-10-23 11:51:03 +00:00
weichen bd5c0f39e6 Pin nested intent, native and squad to the mis-execution fix tips
Why: composition consistency - Dev pins the three containers at their
negation-aware tips; the backend container's nested copies must match
(check-pins).
Evidence: pin only.
Impact: nested gitlink pointer changes.
Refs: feature/recall-misexec-fixes
2026-10-23 04:05:44 +00:00
weichen 8dc91c1783 Pin nested intent, native and squad to the A3 tips
Why: composition consistency - Dev pins the three containers at their
A3 evaluation/repair tips; the backend container's nested copies must
match (check-pins).
Evidence: pin only.
Impact: nested gitlink pointer changes.
Refs: feature/catalog-recall-eval-a3
2026-10-23 01:39:59 +00:00
weichen 330c1a3ffe Merge commit 'f9939dd1d722b997c6f1522237ae2e53fd9e239c' into fix/exec-recovery-diagnostics-a2 2026-10-22 14:56:09 +00:00
agent-exec-transport f9939dd1d7 agent-exec transport b82641a7033ca786ca66a2ca599ea9b5/4ad0f36d42ba 2026-10-22 14:53:01 +00:00
agent-exec-transport 06b0c9f1db Add the read-only run diagnostics endpoint
Why: A2 - answer where a run is stuck, who is responsible, why it
waits, the last stable checkpoint, known side effects and the next
step, from existing records only (recovery holds, HITL decisions,
confirmation cards, audit envelopes).
Evidence: rundiag/server/store tests pass (verify-a2.json); per-state
responsibility, scoping and next-step mapping cases added.
Impact: one read-only GET endpoint plus read helpers; no behavior
change to execution or recovery paths.
Refs: feature/exec-recovery-diagnostics-a2
2026-10-22 13:03:53 +00:00
20 changed files with 2017 additions and 40 deletions
+46 -6
View File
@@ -218,6 +218,12 @@ func (s *Service) ApplyAnswer(key Key, revision int, answer Answer) (*Outcome, e
if err != nil || cur == nil {
return nil, err
}
return s.applyAnswer(key, cur, answer)
}
// applyAnswer 把一轮回答折叠进已读出的会话(读一次判一次:同一条命令里
// 不重复读库,判定口径与读到的状态一致)。
func (s *Service) applyAnswer(key Key, cur *Session, answer Answer) (*Outcome, error) {
if cur.Expectation == nil || cur.Cancelled != nil {
return nil, nil
}
@@ -247,16 +253,33 @@ func (s *Service) ApplyAnswer(key Key, revision int, answer Answer) (*Outcome, e
}
// ApplyCommand 消费库层会话命令:
// - set_slot / change_slot 写槽位(动作声明可解析时按声明校验,非法值不采纳);
// - set_slot / change_slot 写槽位(动作声明可解析时按声明校验,非法值不采纳;
// 键只许落在当前预期认得的槽位上——别的动作的键一律忽略);
// - cancel 清空挂起预期(保留已收集槽位,revision +1 让在途回答作废);
// - 其它种类(start/clarify/chitchat/handoff…)一律忽略,如实返回 nil。
func (s *Service) ApplyCommand(key Key, revision int, kind, name, value string) (*Outcome, error) {
switch kind {
case string(intentgate.CommandSetSlot), string(intentgate.CommandChangeSlot):
if strings.TrimSpace(name) == "" {
name = strings.TrimSpace(name)
if name == "" {
return nil, nil
}
return s.ApplyAnswer(key, revision, Answer{Slots: map[string]string{name: value}})
cur, err := s.current(key, revision)
if err != nil || cur == nil {
return nil, err
}
if cur.Expectation == nil || cur.Cancelled != nil {
// 没有待补齐的动作(或这一行只是已取消记录):如实忽略。
return nil, nil
}
if !cur.ownsSlot(name) {
// 显式落槽命令只许落在当前预期认得的槽位键上:别的动作的键绝不
// 注入当前会话(如给「取消运行」的会话塞「清理对象」的键)。如
// 实忽略(返回 nil=本轮没有任何会话语义),响应面继续按仍缺的
// 槽位澄清,绝不假装消费过这条命令。
return nil, nil
}
return s.applyAnswer(key, cur, Answer{Slots: map[string]string{name: value}})
case string(intentgate.CommandCancel):
cur, err := s.current(key, revision)
if err != nil || cur == nil {
@@ -362,13 +385,14 @@ func (s *Service) load(key Key) (*Session, error) {
}
// live 报告会话是否仍在有效期内。没有挂起预期的行(已取消/已补齐)对宿主
// 等于「没有会话」。
// 等于「没有会话」;到期时间缺失或不可解析(行被写坏)同样 fail-closed:
// 当作已过期,绝不把损坏的到期时间当成「永不过期」。
func (s *Service) live(cur store.IntentSession) bool {
if cur.Expectation == nil {
return false
}
if cur.ExpiresAt.IsZero() {
return true
if !cur.ExpiresValid || cur.ExpiresAt.IsZero() {
return false
}
return s.now().Before(cur.ExpiresAt)
}
@@ -446,6 +470,22 @@ func fromStore(key Key, cur store.IntentSession) *Session {
return out
}
// ownsSlot 报告这个槽位键是否属于当前预期认得的槽位:仍缺的(Missing)
// 与已收集的(Slots)都是当前动作的键;别的动作的键一律不认(显式落槽
// 命令不得把它们注入当前会话)。
func (s *Session) ownsSlot(key string) bool {
if s == nil || s.Expectation == nil {
return false
}
for _, q := range s.Expectation.Missing {
if q.Key == key {
return true
}
}
_, collected := s.Slots[key]
return collected
}
// runIDOf 从槽位表里取运行号(固定键序,结果与 map 迭代序无关;无则空)。
func runIDOf(slots map[string]string) string {
for _, key := range runSlotKeys {
@@ -142,6 +142,60 @@ func TestSetSlotThenAnswerCompletes(t *testing.T) {
}
}
// 显式 set_slot/change_slot 只许落在当前预期认得的槽位上:别的动作的键
// 一律忽略(不落槽、不推进 revision);认得的键照旧生效。
func TestSetSlotOnlyAcceptsKeysOfCurrentExpectation(t *testing.T) {
svc := newService(t)
key := cancelKey()
// 当前预期是「取消运行」:它认得的键只有 run_id 与 reason。
suspended, err := svc.Suspend(key, cancelExpectation(), nil)
if err != nil {
t.Fatal(err)
}
// 别的动作(demo.purge)的键:忽略——绝不注入当前会话。
out, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "target", "T-1")
if err != nil || out != nil {
t.Fatalf("不属于当前预期的键必须被忽略:%+v %v", out, err)
}
cur, err := svc.Pending(key)
if err != nil || cur == nil || cur.Revision != suspended.Revision || cur.Slots["target"] != "" {
t.Fatalf("被忽略的命令改动了会话:%+v %v", cur, err)
}
// 认得的键(仍缺的):照旧落槽。
ok, err := svc.ApplyCommand(key, suspended.Revision, string(intentgate.CommandSetSlot), "run_id", "R-7")
if err != nil || ok == nil || ok.Session == nil {
t.Fatalf("认得的键必须照旧落槽:%+v %v", ok, err)
}
if ok.Session.Slots["run_id"] != "R-7" || ok.Session.Revision != suspended.Revision+1 {
t.Fatalf("落槽结果:%+v", ok.Session)
}
// 已收集的键也认(否则「改一下编号」永远改不动已抽到的槽位)。
changed, err := svc.ApplyCommand(key, ok.Session.Revision, string(intentgate.CommandChangeSlot), "run_id", "R-8")
if err != nil || changed == nil || changed.Session == nil || changed.Session.Slots["run_id"] != "R-8" {
t.Fatalf("change_slot 未改写已收集的键:%+v %v", changed, err)
}
// 补上最后一个缺键:照旧补齐(消费会话)。
filled, err := svc.ApplyCommand(key, changed.Session.Revision, string(intentgate.CommandSetSlot), "reason", "误触发")
if err != nil || filled == nil || !filled.Complete {
t.Fatalf("补齐最后一个键应完成:%+v %v", filled, err)
}
// 反过来:另一个动作的预期认不得上一个动作的键。
purge, err := svc.Suspend(key, Expectation{IntentID: "demo.purge",
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.purge"},
Missing: []intentgate.SlotQuestion{{Key: "target", Question: "清理哪个对象?"}},
Question: "清理哪个对象?"}, nil)
if err != nil {
t.Fatal(err)
}
if out, err := svc.ApplyCommand(key, purge.Revision, string(intentgate.CommandSetSlot), "run_id", "R-9"); err != nil || out != nil {
t.Fatalf("上个动作的键必须被忽略:%+v %v", out, err)
}
after, err := svc.Pending(key)
if err != nil || after == nil || after.Revision != purge.Revision || after.Slots["run_id"] != "" {
t.Fatalf("被忽略的命令改动了会话:%+v %v", after, err)
}
}
// 取消清空挂起预期:确认取消、不假装成功,陈旧回答也作废。
func TestCancelClearsPendingExpectation(t *testing.T) {
svc := newService(t)
@@ -221,6 +275,35 @@ func TestExpiredSessionNeverCompletesOldAction(t *testing.T) {
}
}
// 库里的到期时间被写坏(不可解析):fail-closed 当作已过期——会话对宿主
// 不存在,行被惰性删除;绝不把损坏的到期时间当成「永不过期」。
func TestCorruptedExpiryIsTreatedAsExpired(t *testing.T) {
svc := newService(t)
key := cancelKey()
suspended, err := svc.Suspend(key, cancelExpectation(), map[string]string{"run_id": "R-1"})
if err != nil {
t.Fatal(err)
}
// 直接改坏库里的 expires_at(模拟损坏的行)。
if _, err := svc.st.DB().Exec(`UPDATE intent_session SET expires_at = ? WHERE owner = ? AND conversation_id = ?`,
"not-a-timestamp", key.Owner, key.Conversation); err != nil {
t.Fatal(err)
}
// 损坏的到期时间=已过期:会话对宿主不存在。
got, err := svc.Pending(key)
if err != nil || got != nil {
t.Fatalf("损坏的到期时间必须当作已过期:%v %v", got, err)
}
// 陈旧回答不得借到旧会话的槽位。
if out, err := svc.ApplyAnswer(key, suspended.Revision, Answer{Slots: map[string]string{"reason": "误触发"}}); err != nil || out != nil {
t.Fatalf("过期回答不得补齐旧动作:%+v %v", out, err)
}
// 行已被惰性删除。
if _, err := svc.st.GetIntentSession(key.Owner, key.Project, key.Conversation); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("过期行未被惰性删除:%v", err)
}
}
// 两个用户、两个项目在构造上互不可见。
func TestSessionsAreIsolatedByOwnerAndProject(t *testing.T) {
svc := newService(t)
+548
View File
@@ -0,0 +1,548 @@
// Package rundiag 把单次 run 的诊断证据聚合成一份只读结论:卡在哪、谁负责、
// 为什么在等、最后一个稳定检查点、已知副作用、下一步动作。
//
// 全包统一口径(任务卡 A2 的契约):
// - 只读派生:Build 是纯函数,Gather 只跑 SELECT;本包不提供任何写路径。
// - 证据优先于推断:状态只由已落库的记录驱动。读不到就降级为 unknown + note,
// 读不到隔离记录时更按 fail-closed 处理(不声称"可以安全重试")。
// - 「没有记录」≠「没有发生」:side_effects 为空必须带 note,说明这是"未记录"
// 而不是"未发生"。
// - 有界读取也要说出来:命中读取上限(扫描/聚合上限)时报告显式带截断 note,
// 不让"读了前 N 条"冒充"全部证据"。
// - 归属边界:HITL 决策卡、危险命令确认卡、命令审计信封都按 owner + 会话归属,
// 其中决策卡与确认卡表里根本没有 run 列,审计信封虽有 runId 字段但宿主总线
// 当前不写。因此"归到这个 run"是调用方按签发时间窗做的有界推断,报告以
// note 标出,绝不把会话里更早的记录算成本次 run 的副作用。
package rundiag
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// responsibility.state 的取值域(任务卡固定)。
const (
StateRunning = "running"
StateWaitingHITL = "waiting_hitl"
StateConfirmationPending = "confirmation_pending"
StateRecoveryHold = "recovery_hold"
StateReconciling = "reconciling"
StateFailed = "failed"
StateCompleted = "completed"
StateUnknown = "unknown"
)
// responsibility.role:这一刻该谁动。
const (
RoleRunner = "runner" // 执行器自己在推进
RoleHuman = "human" // 等人裁决(HITL / 危险命令确认)
RoleOperationOwner = "operation-owner" // 等副作用责任人对账与解除
RoleNone = "none" // 没有待办责任方
)
// next_step.action 的取值域。
const (
ActionConfirmCard = "confirm_card"
ActionReleaseRecoveryHold = "release_recovery_hold"
ActionSafeRetry = "safe_retry"
ActionNeedsManualReconciliation = "needs_manual_reconciliation"
ActionNone = "none"
)
// side_effects[].kind:证据来源类别。
const (
EffectOperation = "operation" // 命令审计信封(U7b 对账记录)
EffectRecoveryHold = "recovery_hold" // 恢复隔离行(U3b 的"副作用未知"证据)
)
// 读取上界:诊断是给人和值班看的,不是全量导出。
const (
maxSideEffects = 50
maxAuditScan = 200 // 审计信封按会话+时间窗有界扫描
maxConfirms = 50
)
// Report 是诊断响应(JSON 契约,字段名用任务卡给定的 snake_case)。
type Report struct {
Run RunSummary `json:"run"`
Responsibility Responsibility `json:"responsibility"`
LastCheckpoint *Checkpoint `json:"last_checkpoint"` // 无记录时为 null(不编造)
SideEffects []SideEffect `json:"side_effects"` // 恒为数组:空数组 = 未记录到
NextStep NextStep `json:"next_step"`
Notes []string `json:"notes,omitempty"` // 降级与边界说明
}
// RunSummary 是 run 自身的身份与时间戳。updated_at 的口径:已终态取 ended_at,
// 未终态取最后一个已持久化事件的时间;两者都没有则为 null(并在 notes 说明)。
type RunSummary struct {
ID string `json:"id"`
Status string `json:"status"`
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
Owner string `json:"owner"`
Project string `json:"project"`
Conversation string `json:"conversation"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}
// Responsibility 回答"卡在哪、谁负责、为什么等、从什么时候开始等"。
type Responsibility struct {
State string `json:"state"`
Role string `json:"role"`
WaitReason string `json:"wait_reason"`
Since string `json:"since,omitempty"` // 进入该状态的时间;存储没记就省略
}
// Checkpoint 指向最后一个稳定落盘点(事件流上的 seq 可直接作重放游标)。
type Checkpoint struct {
ID string `json:"id"`
At string `json:"at"`
Type string `json:"type"`
}
// SideEffect 是一条已记录的外部副作用证据。operation 行为命令回执对账记录;
// recovery_hold 行是 U3b 落下的"副作用结果未知"隔离证据(state = held/released)。
type SideEffect struct {
Kind string `json:"kind"`
OperationID string `json:"operation_id,omitempty"`
CommandID string `json:"command_id,omitempty"`
State string `json:"state"`
At string `json:"at,omitempty"`
ReleasedAt string `json:"released_at,omitempty"`
}
// NextStep 是唯一建议的下一步动作(诊断面只建议,不执行)。
type NextStep struct {
Action string `json:"action"`
CardID string `json:"card_id,omitempty"` // confirm_card 可解析时给出卡 ID
Reason string `json:"reason"`
}
// Source 是诊断所需的只读端口(*store.Store 直接满足;测试可替身)。
type Source interface {
GetRecoveryHold(runID string) (store.RecoveryHold, error)
ActiveHITLCardsByRun(runID string) ([]core.HITLCard, error)
PendingHumanDecisionsByConversation(owner, conversationID string, now time.Time) ([]store.HumanDecision, error)
ListOwnerPendingConfirms(owner string, limit int) ([]store.PendingConfirm, error)
LastRunCheckpoint(conversationID, runID string) (store.RunCheckpoint, bool, error)
ListAudits(owner, conversationID string, limit int) ([]agentcommand.AuditEnvelope, error)
}
// Evidence 是 Build 的全部输入(合成数据即可表驱动测试,无需真实存储)。
type Evidence struct {
Run core.Run
Hold *store.RecoveryHold // nil = 没有隔离行;HoldUnknown = 读失败
HoldUnknown bool
Cards []core.HITLCard // 该 run 未关闭的 HITL 卡
Decisions []store.HumanDecision // 会话内未过期的 pending 决策卡
Confirms []store.PendingConfirm // owner 名下未确认的危险命令挂起行
Checkpoint *store.RunCheckpoint // 最后一个已持久化事件
Audits []agentcommand.AuditEnvelope
Notes []string
}
// Gather 只读地把一个 run 的证据读齐。任何一条可选查询失败都不让整个诊断失败:
// 失败降级为 note;隔离记录读不到按 fail-closed 标记(HoldUnknown),因为"读不到
// hold"绝不能被读成"没有 hold"。
func Gather(src Source, run core.Run, now time.Time) Evidence {
ev := Evidence{Run: run}
if src == nil {
ev.HoldUnknown = true
return ev.withNote("diagnostics source unavailable; responsibility is fail-closed unknown")
}
owner := string(run.Owner)
conversation := string(run.ConversationID)
hold, err := src.GetRecoveryHold(string(run.ID))
switch {
case err == nil:
ev.Hold = &hold
case errors.Is(err, store.ErrNotFound):
// 没有隔离行是正常态,不记 note。
default:
ev.HoldUnknown = true
ev = ev.withNote("recovery hold unreadable: " + err.Error())
}
cards, err := src.ActiveHITLCardsByRun(string(run.ID))
if err != nil {
ev = ev.withNote("hitl cards unreadable: " + err.Error())
} else {
ev.Cards = cards
}
decisions, err := src.PendingHumanDecisionsByConversation(owner, conversation, now)
if err != nil {
ev = ev.withNote("human decisions unreadable: " + err.Error())
} else {
ev.Decisions = decisions
}
confirms, err := src.ListOwnerPendingConfirms(owner, maxConfirms)
if err != nil {
ev = ev.withNote("pending confirmations unreadable: " + err.Error())
} else {
ev.Confirms = confirms
if len(confirms) >= maxConfirms {
// 有界读到的行数正好等于上限 = 列表可能被截断;不说出来就会被读成"就这些"。
ev = ev.withNote(fmt.Sprintf("pending confirmation read reached the %d-row cap; the list may be truncated", maxConfirms))
}
}
cp, ok, err := src.LastRunCheckpoint(conversation, string(run.ID))
switch {
case err != nil:
ev = ev.withNote("last checkpoint unreadable: " + err.Error())
case ok:
ev.Checkpoint = &cp
}
audits, err := src.ListAudits(owner, conversation, maxAuditScan)
if err != nil {
ev = ev.withNote("command audit unreadable: " + err.Error())
} else {
ev.Audits = audits
if len(audits) >= maxAuditScan {
// 审计信封按时间倒序取最近 maxAuditScan 条;命中上限说明更早的回执没被读,
// 副作用证据可能不完整。
ev = ev.withNote(fmt.Sprintf("command audit scan reached the %d-envelope cap (newest first); older receipts were not read, so side effect evidence may be incomplete", maxAuditScan))
}
}
return ev
}
// Build 把证据聚合成诊断结论(纯函数,不读写任何状态)。
func Build(ev Evidence) Report {
rep := Report{Run: runSummary(ev), SideEffects: []SideEffect{}}
if ev.Checkpoint != nil {
rep.LastCheckpoint = &Checkpoint{ID: fmt.Sprintf("%d", ev.Checkpoint.Seq), At: ev.Checkpoint.TS, Type: ev.Checkpoint.Type}
}
effects, effectsTruncated := sideEffects(ev)
rep.SideEffects = effects
rep.Notes = append([]string(nil), ev.Notes...)
if effectsTruncated {
rep.Notes = append(rep.Notes, fmt.Sprintf(
"side_effects truncated at the %d-entry cap; recorded effects beyond the cap are not represented in this report",
maxSideEffects))
}
card := waitingCard(ev)
decision := waitingDecision(ev)
confirm := waitingConfirm(ev)
if isTerminalRun(ev.Run.Status) {
// 终态 run 上仍未关闭的决策卡 / 人工决策 / 危险命令确认行是残留记录,不是
// 「现在在等人」。终态优先于证据驱动的等待映射:按终态报结论,并把残留记录
// 写进 note——否则值班会拿着一个已经结束、永远不会回跑的 run 去确认一张卡。
// run.status 原样保留(不隐藏),这里只改责任判定与措辞。
if stale := staleWaitRecords(card, decision, confirm); len(stale) > 0 {
rep.Notes = append(rep.Notes, "run reached terminal status "+string(ev.Run.Status)+
" while "+strings.Join(stale, ", ")+
" stayed open; the run is not waiting on them, treat the leftover record(s) as stale instead of confirming")
card, decision, confirm = nil, nil, nil
}
}
switch {
case ev.HoldUnknown:
// 隔离记录读不到:不猜没有 hold,直接 unknown,且不给"安全重试"。
rep.Notes = append(rep.Notes, "recovery hold state unknown; treat as quarantined until reconciled")
rep.Responsibility = Responsibility{
State: StateUnknown, Role: RoleNone,
WaitReason: "recovery hold state could not be read",
}
rep.NextStep = NextStep{Action: ActionNone, Reason: "cannot prove the run is free of an open recovery hold"}
case openHold(ev):
hold := ev.Hold
rep.Responsibility = Responsibility{
State: StateRecoveryHold, Role: RoleOperationOwner,
WaitReason: holdReason(hold), Since: hold.CreatedAt,
}
rep.NextStep = NextStep{Action: ActionReleaseRecoveryHold, Reason: holdReason(hold)}
case needsReconcile(ev):
reason := "recovery hold released but the run is still interrupted"
since := ""
if ev.Hold != nil {
since = ev.Hold.ReleasedAt
if ev.Hold.Reason != "" {
reason += "; hold reason: " + ev.Hold.Reason
}
}
rep.Responsibility = Responsibility{State: StateReconciling, Role: RoleOperationOwner, WaitReason: reason, Since: since}
rep.NextStep = NextStep{Action: ActionNeedsManualReconciliation, Reason: reason}
case ev.Run.Status == core.RunWaitingHITL || card != nil || decision != nil:
reason := "waiting for a human decision"
since := ""
if decision != nil {
reason = decision.Question
if reason == "" {
reason = "pending human decision on command " + decision.CommandID
}
since = decision.IssuedAt.UTC().Format(time.RFC3339)
} else if card != nil {
reason = card.Question
if reason == "" {
reason = "run is waiting on a HITL card"
}
since = card.CreatedAt
}
rep.Responsibility = Responsibility{State: StateWaitingHITL, Role: RoleHuman, WaitReason: reason, Since: since}
if card == nil && decision == nil {
// 只由 run 状态驱动:没有可解析的卡,也不知道等待从何时开始。
rep.Notes = append(rep.Notes, "run is marked waiting_hitl with no open card recorded; the wait entry time is not stored")
}
rep.NextStep = NextStep{Action: ActionConfirmCard, CardID: hitlCardID(card, decision), Reason: reason}
case confirm != nil:
reason := "dangerous command " + confirm.command + " awaits explicit confirmation"
rep.Responsibility = Responsibility{
State: StateConfirmationPending, Role: RoleHuman,
WaitReason: reason, Since: confirm.row.CreatedAt,
}
rep.NextStep = NextStep{Action: ActionConfirmCard, CardID: confirm.row.CardID, Reason: reason}
case ev.Run.Status == core.RunFailed:
reason := "run failed"
if ev.Run.Error != nil {
reason = fmt.Sprintf("run failed: %s: %s", ev.Run.Error.Code, ev.Run.Error.Message)
}
rep.Responsibility = Responsibility{State: StateFailed, Role: RoleRunner, WaitReason: reason, Since: endedAt(ev.Run)}
// 重试安全性看已记录的副作用证据:没有 open hold 且没有 unknown 回执时,
// 重试不会复制本次运行的后果;否则先对账再重试,不说"安全"。
if unknown := unknownOperations(rep.SideEffects); unknown > 0 {
reason := fmt.Sprintf("terminal failure, but %d recorded operation(s) have an unknown outcome; reconcile side effects before retrying", unknown)
rep.Notes = append(rep.Notes, reason)
rep.NextStep = NextStep{Action: ActionNeedsManualReconciliation, Reason: reason}
} else {
rep.NextStep = NextStep{Action: ActionSafeRetry, Reason: "terminal failure with no open recovery hold and no unknown receipt; retry is safe to attempt"}
}
case ev.Run.Status == core.RunCompleted:
rep.Responsibility = Responsibility{State: StateCompleted, Role: RoleNone, WaitReason: "run completed", Since: endedAt(ev.Run)}
rep.NextStep = NextStep{Action: ActionNone, Reason: "run already completed"}
case ev.Run.Status == core.RunRunning || ev.Run.Status == core.RunPending:
rep.Responsibility = Responsibility{
State: StateRunning, Role: RoleRunner,
WaitReason: "run is executing", Since: ev.Run.CreatedAt.UTC().Format(time.RFC3339),
}
rep.NextStep = NextStep{Action: ActionNone, Reason: "runner is executing; no operator action"}
default:
reason := fmt.Sprintf("run status %q has no diagnostic mapping", ev.Run.Status)
rep.Notes = append(rep.Notes, reason)
rep.Responsibility = Responsibility{State: StateUnknown, Role: RoleNone, WaitReason: reason, Since: endedAt(ev.Run)}
rep.NextStep = NextStep{Action: ActionNone, Reason: "status is unmapped; inspect the run record manually"}
}
if ev.Checkpoint == nil {
rep.Notes = append(rep.Notes, "no persisted checkpoint reference for this run (no durable stream event)")
}
if len(rep.SideEffects) == 0 {
rep.Notes = append(rep.Notes, "no recorded side effect evidence; absence of records is not proof of no effect")
}
return rep
}
func runSummary(ev Evidence) RunSummary {
summary := RunSummary{
ID: string(ev.Run.ID), Status: string(ev.Run.Status), Kind: string(ev.Run.Kind),
Phase: string(ev.Run.Phase), Owner: string(ev.Run.Owner), Project: string(ev.Run.ProjectID),
Conversation: string(ev.Run.ConversationID),
}
if !ev.Run.CreatedAt.IsZero() {
summary.CreatedAt = ev.Run.CreatedAt.UTC().Format(time.RFC3339)
}
// updated:终态取 ended_at,未终态取最后一次落盘(检查点)的时间。
if ended := endedAt(ev.Run); ended != "" {
summary.UpdatedAt = &ended
} else if ev.Checkpoint != nil && ev.Checkpoint.TS != "" {
ts := ev.Checkpoint.TS
summary.UpdatedAt = &ts
}
return summary
}
// sideEffects 汇总已记录的外部副作用证据:命令回执对账记录(U7b 审计信封)加上
// 恢复隔离行(U3b 的"结果未知"证据)。第二个返回值是截断标志:命中 maxSideEffects
// 上限时为 true,调用方据此给报告加显式截断 note(有界读不冒充全量)。
func sideEffects(ev Evidence) ([]SideEffect, bool) {
out := []SideEffect{}
if ev.Hold != nil {
state := "held"
if ev.Hold.ReleasedAt != "" {
state = "released"
}
out = append(out, SideEffect{
Kind: EffectRecoveryHold, State: state, At: ev.Hold.CreatedAt, ReleasedAt: ev.Hold.ReleasedAt,
})
}
for _, env := range ev.Audits {
if !belongsToRun(ev, env) {
continue
}
if len(out) >= maxSideEffects {
// 上限已满且还有归属本次 run 的回执:后面的没进报告,如实标出截断。
return out, true
}
out = append(out, SideEffect{
Kind: EffectOperation, OperationID: env.OperationID, CommandID: env.CommandID,
State: string(env.State), At: env.Time.UTC().Format(time.RFC3339),
})
}
return out, false
}
// belongsToRun 是审计信封的归属边界:信封自带 runId 时以它为准;否则只在"同一
// owner + 会话且签发时间不早于 run 创建时间"的窗口内归属——会话里更早的记录属于
// 更早的 run,不算进本次副作用。
func belongsToRun(ev Evidence, env agentcommand.AuditEnvelope) bool {
if env.RunID != "" {
return env.RunID == string(ev.Run.ID)
}
if string(env.ConversationID) != "" && string(env.ConversationID) != string(ev.Run.ConversationID) {
return false
}
if !ev.Run.CreatedAt.IsZero() && !env.Time.IsZero() && env.Time.Before(ev.Run.CreatedAt) {
return false
}
return true
}
func openHold(ev Evidence) bool {
return ev.Hold != nil && ev.Hold.ReleasedAt == ""
}
// isTerminalRun 判定 run 是否已到终态(failed / completed / cancelled:runner 不会再
// 推进它)。终态优先于证据驱动的等待映射——终态 run 上残留的卡不再构成"在等人"。
func isTerminalRun(status core.RunStatus) bool {
switch status {
case core.RunFailed, core.RunCompleted, core.RunCancelled:
return true
default:
return false
}
}
// staleWaitRecords 列出终态 run 上仍未关闭的等待类记录,供 note 如实点名(不点名就等于
// 把残留记录藏起来)。它不改变任何结论,只决定报告里怎么称呼残留证据。
func staleWaitRecords(card *core.HITLCard, decision *store.HumanDecision, confirm *scopedConfirm) []string {
out := []string{}
if card != nil {
out = append(out, "hitl card "+string(card.ID))
}
if decision != nil {
out = append(out, "pending human decision "+decision.ID)
}
if confirm != nil {
out = append(out, "pending command confirmation "+confirm.row.CardID)
}
return out
}
// unknownOperations 统计结果未知的已记录 operation(四态语义里的 unknown:
// 执行过但结果不确定)。它是"重试会不会复制副作用"的直接证据。
func unknownOperations(effects []SideEffect) int {
n := 0
for _, e := range effects {
if e.Kind == EffectOperation && e.State == string(agentcommand.StateUnknown) {
n++
}
}
return n
}
// needsReconcile 是 U3b 隔离解除后的残留态:隔离已解除,但 run 仍停在中断态——
// 没有人把它推进到终态,需要人工对账。
func needsReconcile(ev Evidence) bool {
return ev.Hold != nil && ev.Hold.ReleasedAt != "" && ev.Run.Status == core.RunInterrupted
}
func holdReason(hold *store.RecoveryHold) string {
if hold == nil || hold.Reason == "" {
return "external side effect outcome unknown (recovery hold)"
}
return hold.Reason
}
func endedAt(run core.Run) string {
if run.EndedAt == nil {
return ""
}
return run.EndedAt.UTC().Format(time.RFC3339)
}
// waitingCard 取该 run 未关闭的 HITL 卡(最早一张;只看未关闭的)。
func waitingCard(ev Evidence) *core.HITLCard {
for i := range ev.Cards {
if ev.Cards[i].Resolved || ev.Cards[i].Blocked {
continue
}
return &ev.Cards[i]
}
return nil
}
// waitingDecision 取本 run 生命周期内签发、仍未过期的人工决策卡。
func waitingDecision(ev Evidence) *store.HumanDecision {
for i := range ev.Decisions {
rec := &ev.Decisions[i]
if rec.Status != "pending" {
continue
}
if !rec.IssuedAt.IsZero() && !ev.Run.CreatedAt.IsZero() && rec.IssuedAt.Before(ev.Run.CreatedAt) {
continue // 签发在本次 run 之前:属于更早的等待,不算本次
}
return rec
}
return nil
}
type scopedConfirm struct {
row store.PendingConfirm
command string
}
// waitingConfirm 取本 run 生命周期内、同一会话挂起的危险命令确认行。载荷是总线
// 落下的分发请求;解析不出会话的旧行按"无记录"处理,不回显原始载荷。
func waitingConfirm(ev Evidence) *scopedConfirm {
for _, row := range ev.Confirms {
req := cmdbus.Request{}
if err := json.Unmarshal([]byte(row.Payload), &req); err != nil {
continue
}
if req.ConversationID != "" && req.ConversationID != string(ev.Run.ConversationID) {
continue
}
if req.Owner != "" && string(ev.Run.Owner) != "" && req.Owner != string(ev.Run.Owner) {
continue
}
created, err := time.Parse(time.RFC3339, row.CreatedAt)
if err == nil && !ev.Run.CreatedAt.IsZero() && created.Before(ev.Run.CreatedAt) {
continue // 挂起早于本次 run:属于更早的等待
}
return &scopedConfirm{row: row, command: req.ID}
}
return nil
}
// hitlCardID 给出可解析的卡标识:HITL 卡优先,其次决策卡 ID。
func hitlCardID(card *core.HITLCard, decision *store.HumanDecision) string {
if card != nil {
return string(card.ID)
}
if decision != nil {
return decision.ID
}
return ""
}
func (ev Evidence) withNote(note string) Evidence {
ev.Notes = append(ev.Notes, note)
return ev
}
+582
View File
@@ -0,0 +1,582 @@
package rundiag
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
var (
baseTime = time.Date(2026, 10, 20, 10, 0, 0, 0, time.UTC)
afterRun = baseTime.Add(5 * time.Minute)
)
// fakeSource 是只读端口的合成替身:每个字段都能单独触发"没有记录"或"读取失败"。
type fakeSource struct {
hold store.RecoveryHold
holdErr error
cards []core.HITLCard
decisions []store.HumanDecision
confirms []store.PendingConfirm
cp store.RunCheckpoint
cpOK bool
audits []agentcommand.AuditEnvelope
}
func (f fakeSource) GetRecoveryHold(string) (store.RecoveryHold, error) {
if f.holdErr != nil {
return store.RecoveryHold{}, f.holdErr
}
if f.hold.RunID == "" {
return store.RecoveryHold{}, store.ErrNotFound
}
return f.hold, nil
}
func (f fakeSource) ActiveHITLCardsByRun(string) ([]core.HITLCard, error) { return f.cards, nil }
func (f fakeSource) PendingHumanDecisionsByConversation(string, string, time.Time) ([]store.HumanDecision, error) {
return f.decisions, nil
}
func (f fakeSource) ListOwnerPendingConfirms(string, int) ([]store.PendingConfirm, error) {
return f.confirms, nil
}
func (f fakeSource) LastRunCheckpoint(string, string) (store.RunCheckpoint, bool, error) {
return f.cp, f.cpOK, nil
}
func (f fakeSource) ListAudits(string, string, int) ([]agentcommand.AuditEnvelope, error) {
return f.audits, nil
}
func run(status core.RunStatus) core.Run {
return core.Run{
ID: "run_1", ConversationID: "cnv_1", ProjectID: "prj_1", Owner: "alice",
Kind: core.RunConversation, Status: status, Phase: core.PhaseResponding, CreatedAt: baseTime,
}
}
func ended(r core.Run, at time.Time) core.Run {
r.EndedAt = &at
return r
}
func checkpoint() *store.RunCheckpoint {
return &store.RunCheckpoint{Seq: 7, TS: baseTime.Add(2 * time.Minute).Format(time.RFC3339), Type: "agent.progress"}
}
// A2 契约:每个 responsibility.state 都有驱动它的记录,且 next_step 逐状态映射正确。
func TestBuildResponsibilityStatesAndNextStep(t *testing.T) {
cases := []struct {
name string
ev Evidence
state string
role string
action string
cardID string
waitHint string
since string
}{
{
name: "unreleased recovery hold wins over everything",
ev: Evidence{
Run: run(core.RunRunning),
Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown external effect",
CreatedAt: afterRun.Format(time.RFC3339)},
Cards: []core.HITLCard{{ID: "card_hitl", Resolved: false, Question: "approve?"}},
},
state: StateRecoveryHold, role: RoleOperationOwner, action: ActionReleaseRecoveryHold,
waitHint: "unknown external effect", since: afterRun.Format(time.RFC3339),
},
{
name: "released hold on an interrupted run needs reconciliation",
ev: Evidence{
Run: run(core.RunInterrupted),
Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown external effect",
CreatedAt: afterRun.Format(time.RFC3339), ReleasedAt: afterRun.Add(time.Minute).Format(time.RFC3339)},
},
state: StateReconciling, role: RoleOperationOwner, action: ActionNeedsManualReconciliation,
waitHint: "still interrupted", since: afterRun.Add(time.Minute).Format(time.RFC3339),
},
{
name: "released hold reconciles even while a HITL card is still open",
ev: Evidence{
Run: run(core.RunInterrupted),
Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown external effect",
CreatedAt: afterRun.Format(time.RFC3339), ReleasedAt: afterRun.Add(time.Minute).Format(time.RFC3339)},
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve?"}},
},
state: StateReconciling, role: RoleOperationOwner, action: ActionNeedsManualReconciliation,
waitHint: "still interrupted", since: afterRun.Add(time.Minute).Format(time.RFC3339),
},
{
name: "a pending decision outranks a pending dangerous command confirmation",
ev: Evidence{
Run: run(core.RunRunning),
Decisions: []store.HumanDecision{{ID: "hd_1", CommandID: "rdm.confirm", Status: "pending",
Question: "approve delivery?", IssuedAt: afterRun, ExpiresAt: afterRun.Add(time.Hour)}},
Confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339),
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.docs.instantiate", Owner: "alice", ConversationID: "cnv_1"})}},
},
state: StateWaitingHITL, role: RoleHuman, action: ActionConfirmCard, cardID: "hd_1",
waitHint: "approve delivery?", since: afterRun.Format(time.RFC3339),
},
{
name: "a terminal failed run with a leftover card is failed, not a live wait",
ev: Evidence{
Run: ended(run(core.RunFailed), afterRun),
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve?"}},
},
state: StateFailed, role: RoleRunner, action: ActionSafeRetry,
waitHint: "run failed", since: afterRun.Format(time.RFC3339),
},
{
name: "waiting on a HITL card",
ev: Evidence{
Run: run(core.RunWaitingHITL),
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve the plan?"}},
},
state: StateWaitingHITL, role: RoleHuman, action: ActionConfirmCard, cardID: "card_hitl",
waitHint: "approve the plan?", since: afterRun.Format(time.RFC3339),
},
{
name: "waiting on a pending human decision without a run card",
ev: Evidence{
Run: run(core.RunRunning),
Decisions: []store.HumanDecision{{ID: "hd_1", CommandID: "rdm.confirm", Status: "pending",
Question: "approve delivery?", IssuedAt: afterRun, ExpiresAt: afterRun.Add(time.Hour)}},
},
state: StateWaitingHITL, role: RoleHuman, action: ActionConfirmCard, cardID: "hd_1",
waitHint: "approve delivery?", since: afterRun.Format(time.RFC3339),
},
{
name: "dangerous command awaiting confirmation",
ev: Evidence{
Run: run(core.RunRunning),
Confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339),
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.docs.instantiate", Owner: "alice", ConversationID: "cnv_1"})}},
},
state: StateConfirmationPending, role: RoleHuman, action: ActionConfirmCard, cardID: "card_cmd",
waitHint: "rdm.docs.instantiate", since: afterRun.Format(time.RFC3339),
},
{
name: "terminal failure is a safe retry",
ev: Evidence{
Run: ended(func() core.Run {
r := run(core.RunFailed)
r.Error = &core.ErrorPart{Code: "run.tool_failed", Message: "tool crashed"}
return r
}(), afterRun),
},
state: StateFailed, role: RoleRunner, action: ActionSafeRetry,
waitHint: "run.tool_failed: tool crashed", since: afterRun.Format(time.RFC3339),
},
{
name: "terminal failure with an unknown receipt needs reconciliation, not a retry",
ev: Evidence{
Run: ended(run(core.RunFailed), afterRun),
Audits: []agentcommand.AuditEnvelope{{Time: afterRun, CommandID: "rdm.docs.instantiate",
OperationID: "op_unknown", State: agentcommand.StateUnknown, ConversationID: "cnv_1"}},
},
state: StateFailed, role: RoleRunner, action: ActionNeedsManualReconciliation,
waitHint: "run failed",
},
{
name: "completed needs no action",
ev: Evidence{Run: ended(run(core.RunCompleted), afterRun)},
state: StateCompleted, role: RoleNone, action: ActionNone,
waitHint: "completed", since: afterRun.Format(time.RFC3339),
},
{
name: "executing run stays with the runner",
ev: Evidence{Run: run(core.RunRunning)},
state: StateRunning, role: RoleRunner, action: ActionNone,
waitHint: "executing", since: baseTime.Format(time.RFC3339),
},
{
name: "pending run reads as running",
ev: Evidence{Run: run(core.RunPending)},
state: StateRunning, role: RoleRunner, action: ActionNone,
waitHint: "executing",
},
{
name: "cancelled has no mapping and degrades to unknown",
ev: Evidence{Run: ended(run(core.RunCancelled), afterRun)},
state: StateUnknown, role: RoleNone, action: ActionNone,
waitHint: "no diagnostic mapping",
},
{
name: "interrupted without any hold record degrades to unknown",
ev: Evidence{Run: run(core.RunInterrupted)},
state: StateUnknown, role: RoleNone, action: ActionNone,
waitHint: "no diagnostic mapping",
},
{
name: "unreadable hold is fail-closed unknown, never a safe retry",
ev: Evidence{Run: run(core.RunFailed), HoldUnknown: true},
state: StateUnknown, role: RoleNone, action: ActionNone,
waitHint: "could not be read",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Build(tc.ev)
if rep.Responsibility.State != tc.state {
t.Fatalf("state=%q want %q (responsibility=%+v)", rep.Responsibility.State, tc.state, rep.Responsibility)
}
if rep.Responsibility.Role != tc.role {
t.Fatalf("role=%q want %q", rep.Responsibility.Role, tc.role)
}
if rep.NextStep.Action != tc.action {
t.Fatalf("next_step=%q want %q (reason=%q)", rep.NextStep.Action, tc.action, rep.NextStep.Reason)
}
if rep.NextStep.CardID != tc.cardID {
t.Fatalf("next_step.card_id=%q want %q", rep.NextStep.CardID, tc.cardID)
}
if tc.waitHint != "" && !strings.Contains(rep.Responsibility.WaitReason, tc.waitHint) {
t.Fatalf("wait_reason=%q does not contain %q", rep.Responsibility.WaitReason, tc.waitHint)
}
if tc.since != "" && rep.Responsibility.Since != tc.since {
t.Fatalf("since=%q want %q", rep.Responsibility.Since, tc.since)
}
if rep.NextStep.Reason == "" {
t.Fatal("next_step.reason must never be empty")
}
})
}
}
// 副作用聚合:命令回执对账记录并入;会话里更早的记录不算本次 run 的副作用。
func TestBuildSideEffectsAggregateRecordedOperations(t *testing.T) {
ev := Evidence{
Run: run(core.RunFailed),
Audits: []agentcommand.AuditEnvelope{
{Time: baseTime.Add(-time.Hour), CommandID: "rdm.reqs.add", OperationID: "op_older",
State: agentcommand.StateCommitted, ConversationID: "cnv_1"},
{Time: afterRun, CommandID: "rdm.docs.instantiate", OperationID: "op_1",
State: agentcommand.StateUnknown, ConversationID: "cnv_1"},
{Time: afterRun, CommandID: "other.run.command", OperationID: "op_2",
State: agentcommand.StateCommitted, ConversationID: "cnv_other"},
{Time: afterRun, CommandID: "rdm.trace.confirm", OperationID: "op_3",
State: agentcommand.StateAccepted, ConversationID: "cnv_1", RunID: "run_1"},
},
}
rep := Build(ev)
if len(rep.SideEffects) != 2 {
t.Fatalf("side effects must hold only this run's receipts: %+v", rep.SideEffects)
}
got := map[string]SideEffect{}
for _, se := range rep.SideEffects {
got[se.OperationID] = se
}
recorded, ok := got["op_1"]
if !ok || recorded.Kind != EffectOperation || recorded.CommandID != "rdm.docs.instantiate" || recorded.State != string(agentcommand.StateUnknown) {
t.Fatalf("recorded operation missing from aggregation: %+v", rep.SideEffects)
}
if _, ok := got["op_older"]; ok {
t.Fatal("a receipt issued before the run started must not be attributed to it")
}
if _, ok := got["op_2"]; ok {
t.Fatal("a receipt from another conversation must not be attributed to this run")
}
if _, ok := got["op_3"]; !ok {
t.Fatal("a receipt carrying this run id must be attributed to it")
}
}
// hold 行本身也是"副作用结果未知"的持久证据(U3b),并标出是否已解除。
func TestBuildSideEffectsIncludeRecoveryHoldEvidence(t *testing.T) {
ev := Evidence{Run: run(core.RunInterrupted), Hold: &store.RecoveryHold{RunID: "run_1", Owner: "alice",
Reason: "unknown", CreatedAt: afterRun.Format(time.RFC3339)}}
rep := Build(ev)
if len(rep.SideEffects) != 1 || rep.SideEffects[0].Kind != EffectRecoveryHold || rep.SideEffects[0].State != "held" {
t.Fatalf("open hold must show as held evidence: %+v", rep.SideEffects)
}
ev.Hold.ReleasedAt = afterRun.Add(time.Minute).Format(time.RFC3339)
rep = Build(ev)
if rep.SideEffects[0].State != "released" || rep.SideEffects[0].ReleasedAt == "" {
t.Fatalf("released hold must keep its release time: %+v", rep.SideEffects[0])
}
}
// 没有落盘检查点时给 null + note,绝不编一个;空副作用同样带 note。
func TestBuildNeverFabricatesCheckpointOrAbsence(t *testing.T) {
rep := Build(Evidence{Run: run(core.RunRunning)})
if rep.LastCheckpoint != nil {
t.Fatalf("checkpoint must stay null when nothing is persisted: %+v", rep.LastCheckpoint)
}
if !containsNote(rep.Notes, "checkpoint") {
t.Fatalf("missing checkpoint note: %v", rep.Notes)
}
if !containsNote(rep.Notes, "absence of records is not proof") {
t.Fatalf("missing side effect absence note: %v", rep.Notes)
}
if rep.SideEffects == nil {
t.Fatal("side_effects must serialize as an array, never null")
}
withCheckpoint := Build(Evidence{Run: run(core.RunRunning), Checkpoint: checkpoint()})
if withCheckpoint.LastCheckpoint == nil || withCheckpoint.LastCheckpoint.ID != "7" || withCheckpoint.Run.UpdatedAt == nil {
t.Fatalf("persisted checkpoint must be surfaced: %+v", withCheckpoint)
}
if *withCheckpoint.Run.UpdatedAt != checkpoint().TS {
t.Fatalf("updated_at must fall back to the last persisted event: %v", *withCheckpoint.Run.UpdatedAt)
}
}
// Gather:只读端口的读齐与降级(读不到隔离记录按 fail-closed)。
func TestGatherReadsEvidenceAndDegradesOnError(t *testing.T) {
src := fakeSource{
hold: store.RecoveryHold{RunID: "run_1", Owner: "alice", Reason: "unknown", CreatedAt: afterRun.Format(time.RFC3339)},
cards: []core.HITLCard{{ID: "card_hitl"}},
cp: store.RunCheckpoint{Seq: 3, TS: afterRun.Format(time.RFC3339), Type: "agent.progress"},
cpOK: true,
audits: []agentcommand.AuditEnvelope{{Time: afterRun, CommandID: "rdm.confirm", State: agentcommand.StateAccepted}},
confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339), Payload: "{}"}},
}
ev := Gather(src, run(core.RunRunning), afterRun.Add(time.Minute))
if ev.Hold == nil || ev.HoldUnknown || len(ev.Cards) != 1 || ev.Checkpoint == nil || len(ev.Audits) != 1 || len(ev.Confirms) != 1 {
t.Fatalf("gather missed evidence: %+v", ev)
}
if rep := Build(ev); rep.Responsibility.State != StateRecoveryHold {
t.Fatalf("state=%q want %q", rep.Responsibility.State, StateRecoveryHold)
}
degraded := Gather(fakeSource{holdErr: errors.New("db gone")}, run(core.RunRunning), afterRun)
if !degraded.HoldUnknown || degraded.Hold != nil {
t.Fatalf("an unreadable hold must stay unknown, never absent: %+v", degraded)
}
rep := Build(degraded)
if rep.Responsibility.State != StateUnknown || rep.NextStep.Action == ActionSafeRetry {
t.Fatalf("unreadable hold must not yield a retry: %+v", rep)
}
if !containsNote(rep.Notes, "fail-closed") && !containsNote(rep.Notes, "quarantined") {
t.Fatalf("missing fail-closed note: %v", rep.Notes)
}
// 没有隔离行是正常态:不产生噪音 note。
normal := Build(Gather(fakeSource{}, run(core.RunRunning), afterRun))
if containsNote(normal.Notes, "recovery hold unreadable") {
t.Fatalf("a missing hold row is not a degraded read: %v", normal.Notes)
}
}
// 契约形状:snake_case 字段名固定,last_checkpoint 为 null 时显式出现。
func TestReportJSONContract(t *testing.T) {
raw, err := json.Marshal(Build(Evidence{Run: run(core.RunRunning), Checkpoint: checkpoint()}))
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
for _, key := range []string{"run", "responsibility", "last_checkpoint", "side_effects", "next_step"} {
if _, ok := out[key]; !ok {
t.Fatalf("missing contract key %q in %s", key, raw)
}
}
responsibility, _ := out["responsibility"].(map[string]any)
for _, key := range []string{"state", "role", "wait_reason", "since"} {
if _, ok := responsibility[key]; !ok {
t.Fatalf("missing responsibility key %q in %s", key, raw)
}
}
runSummary, _ := out["run"].(map[string]any)
for _, key := range []string{"id", "status", "owner", "project", "conversation", "created_at", "updated_at"} {
if _, ok := runSummary[key]; !ok {
t.Fatalf("missing run key %q in %s", key, raw)
}
}
nextStep, _ := out["next_step"].(map[string]any)
for _, key := range []string{"action", "reason"} {
if _, ok := nextStep[key]; !ok {
t.Fatalf("missing next_step key %q in %s", key, raw)
}
}
empty, err := json.Marshal(Build(Evidence{Run: run(core.RunRunning)}))
if err != nil {
t.Fatal(err)
}
var nulled map[string]any
if err := json.Unmarshal(empty, &nulled); err != nil {
t.Fatal(err)
}
if nulled["last_checkpoint"] != nil {
t.Fatalf("last_checkpoint must be null (not omitted) when nothing is persisted: %s", empty)
}
if _, ok := nulled["side_effects"].([]any); !ok {
t.Fatalf("side_effects must be an array: %s", empty)
}
}
// 归属边界:会话里更早签发的决策卡与确认行不算本次 run 在等的东西。
func TestBuildIgnoresEvidenceIssuedBeforeTheRun(t *testing.T) {
ev := Evidence{
Run: run(core.RunRunning),
Decisions: []store.HumanDecision{{ID: "hd_old", Status: "pending", Question: "older?",
IssuedAt: baseTime.Add(-time.Hour), ExpiresAt: baseTime.Add(time.Hour)}},
Confirms: []store.PendingConfirm{{CardID: "card_old", Owner: "alice",
CreatedAt: baseTime.Add(-time.Hour).Format(time.RFC3339),
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.confirm", Owner: "alice", ConversationID: "cnv_1"})}},
}
rep := Build(ev)
if rep.Responsibility.State != StateRunning {
t.Fatalf("state=%q want %q: earlier conversation evidence must not hold this run", rep.Responsibility.State, StateRunning)
}
}
// 只有 run 状态写着 waiting_hitl、没有可解析的卡时:状态仍然成立,但必须说清楚
// 等待起点没被存储。
func TestBuildStatusDrivenWaitHasNoCardAndSaysSo(t *testing.T) {
rep := Build(Evidence{Run: run(core.RunWaitingHITL)})
if rep.Responsibility.State != StateWaitingHITL || rep.NextStep.Action != ActionConfirmCard {
t.Fatalf("status-driven wait: %+v", rep.Responsibility)
}
if rep.NextStep.CardID != "" {
t.Fatalf("an unresolvable card must not be invented: %q", rep.NextStep.CardID)
}
if rep.Responsibility.Since != "" {
t.Fatalf("unknown wait entry time must stay empty: %q", rep.Responsibility.Since)
}
if !containsNote(rep.Notes, "wait entry time is not stored") {
t.Fatalf("missing status-driven wait note: %v", rep.Notes)
}
}
// MI1:终态 run 上的残留卡不再驱动「等人确认」。终态映射优先,run.status 原样可见,
// 残留记录逐一点名——而不是把一个永远不会回跑的 run 的卡交给值班去确认。
func TestBuildTerminalRunSuppressesStaleWaitEvidence(t *testing.T) {
leftovers := func() Evidence {
return Evidence{
Run: ended(run(core.RunFailed), afterRun),
Cards: []core.HITLCard{{ID: "card_hitl", CreatedAt: afterRun.Format(time.RFC3339), Question: "approve?"}},
Decisions: []store.HumanDecision{{ID: "hd_1", CommandID: "rdm.confirm", Status: "pending",
Question: "approve delivery?", IssuedAt: afterRun, ExpiresAt: afterRun.Add(time.Hour)}},
Confirms: []store.PendingConfirm{{CardID: "card_cmd", Owner: "alice", CreatedAt: afterRun.Format(time.RFC3339),
Payload: mustRequest(t, cmdbus.Request{ID: "rdm.docs.instantiate", Owner: "alice", ConversationID: "cnv_1"})}},
}
}
rep := Build(leftovers())
if rep.Responsibility.State != StateFailed {
t.Fatalf("a terminal run must not read as waiting on a card: %+v", rep.Responsibility)
}
if rep.NextStep.Action == ActionConfirmCard || rep.NextStep.CardID != "" {
t.Fatalf("a terminal run must not advise confirming a leftover card: %+v", rep.NextStep)
}
if rep.Run.Status != string(core.RunFailed) {
t.Fatalf("run.status must stay visible: %q", rep.Run.Status)
}
for _, want := range []string{"stale", "hitl card card_hitl", "pending human decision hd_1", "pending command confirmation card_cmd"} {
if !containsNote(rep.Notes, want) {
t.Fatalf("terminal run with leftovers must note %q: %v", want, rep.Notes)
}
}
completed := leftovers()
completed.Run = ended(run(core.RunCompleted), afterRun)
rep = Build(completed)
if rep.Responsibility.State != StateCompleted || rep.NextStep.Action != ActionNone || rep.NextStep.CardID != "" {
t.Fatalf("completed run with leftovers: %+v", rep)
}
if !containsNote(rep.Notes, "stale") {
t.Fatalf("completed run with leftovers must name them: %v", rep.Notes)
}
cancelled := leftovers()
cancelled.Run = ended(run(core.RunCancelled), afterRun)
rep = Build(cancelled)
if rep.Responsibility.State != StateUnknown || rep.NextStep.Action == ActionConfirmCard || rep.NextStep.CardID != "" {
t.Fatalf("cancelled run with leftovers: %+v", rep)
}
if !containsNote(rep.Notes, "stale") {
t.Fatalf("cancelled run with leftovers must name them: %v", rep.Notes)
}
// 未终态的 run 不受影响:卡仍然驱动等待映射,且不得被说成残留。
live := leftovers()
live.Run = run(core.RunRunning)
rep = Build(live)
if rep.Responsibility.State != StateWaitingHITL || rep.NextStep.Action != ActionConfirmCard || rep.NextStep.CardID != "card_hitl" {
t.Fatalf("a live run must keep its wait mapping: %+v", rep)
}
if containsNote(rep.Notes, "stale") {
t.Fatalf("a live run must not be reported as stale: %v", rep.Notes)
}
}
// MI2:有界读取命中上限时必须显式说截断,不能让「读了前 N 条」冒充「全部证据」。
func TestBuildReportsReadCapsInsteadOfSilentTruncation(t *testing.T) {
// 审计信封按时间倒序取最近 maxAuditScan 条;命中上限即说明更早的回执没被读。
rep := Build(Gather(fakeSource{audits: envelopes(maxAuditScan + 5)}, run(core.RunRunning), afterRun))
if !containsNote(rep.Notes, "audit scan reached the") {
t.Fatalf("a capped audit scan must be reported: %v", rep.Notes)
}
// 副作用聚合上限:超限的回执不进报告,必须标出截断。
rep = Build(Evidence{Run: ended(run(core.RunFailed), afterRun), Audits: envelopes(maxSideEffects + 10)})
if len(rep.SideEffects) != maxSideEffects {
t.Fatalf("side effects must stop at the read cap: %d", len(rep.SideEffects))
}
if !containsNote(rep.Notes, "side_effects truncated at the") {
t.Fatalf("capped side effects must be reported as truncated: %v", rep.Notes)
}
// 确认行上限同样要标出来。
confirms := make([]store.PendingConfirm, maxConfirms)
for i := range confirms {
confirms[i] = store.PendingConfirm{CardID: fmt.Sprintf("card_%d", i), Owner: "alice",
CreatedAt: afterRun.Format(time.RFC3339), Payload: "{}"}
}
rep = Build(Gather(fakeSource{confirms: confirms}, run(core.RunRunning), afterRun))
if !containsNote(rep.Notes, "confirmation read reached the") {
t.Fatalf("a capped confirmation read must be reported: %v", rep.Notes)
}
// 未命中上限时不加噪音 note。
clean := Build(Evidence{Run: run(core.RunRunning), Audits: envelopes(3)})
if containsNote(clean.Notes, "truncat") {
t.Fatalf("an uncapped read must not claim truncation: %v", clean.Notes)
}
}
// envelopes 造 n 条归属本次 run 的回执信封(RunID 显式带上,绕过时间窗推断)。
func envelopes(n int) []agentcommand.AuditEnvelope {
out := make([]agentcommand.AuditEnvelope, 0, n)
for i := 0; i < n; i++ {
out = append(out, agentcommand.AuditEnvelope{
RunID: "run_1", CommandID: "rdm.docs.instantiate", OperationID: fmt.Sprintf("op_%d", i),
State: agentcommand.StateCommitted, Time: afterRun.Add(time.Duration(i) * time.Second),
})
}
return out
}
func mustRequest(t *testing.T, req cmdbus.Request) string {
t.Helper()
raw, err := json.Marshal(req)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func containsNote(notes []string, want string) bool {
for _, n := range notes {
if strings.Contains(n, want) {
return true
}
}
return false
}
+14 -1
View File
@@ -54,7 +54,7 @@ type directRun struct {
acting bool
queued []agentllm.ToolCall
inflight map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
toolMu sync.Mutex // 事件回调与工具 goroutine 之间互斥(inflight/finalParts/queued)
turnBody string
evidence []toolEvidence
@@ -763,6 +763,15 @@ func (st *directRun) onEvent(ev nruntime.Event) {
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
st.acting = true
}
// queued 的判空与 takeQueued 不持 toolMu:它们与 message_end 里对 queued 的
// 写入都在同一个事件回调(onEvent)里,而内核把宿主回调串行化——Run 入口的
// serializeHostCallbacks 包装 Config.OnEvent(agent-native 循环),并行
// executeParallel 的每个 goroutine 各发一个 tool_execution_start,但它们逐个
// 进入 onEvent,不会并发;于是取用与写入天然互斥(配对结果不因此改变,只是
// 不再依赖“恰好没人同时来”)。对比:inflight 槽位与 finalParts 还被工具
// goroutine(directRun.writeSlot / appendFinalPart)写,那条路径不在回调串行化
// 范围内,必须持 toolMu——takeInflight / writeSlot 的“调用方须持有 toolMu”
// 靠那把锁兑现。
if len(st.queued) == 0 {
return
}
@@ -1440,6 +1449,10 @@ func toolExecMode(eff core.EffectiveSettings) string {
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
//
// queued 的并发前提:内核串行化宿主事件回调,takeQueued 与写 queued 的 message_end
// 不会并发(见 onEvent 的 tool_execution_start 分支说明),因此调用点可以不持锁;
// 本函数的其余调用方仍应持 toolMu(读改写非原子,脱离回调串行化即数据竞争)。
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
if callID != "" {
for i, c := range st.queued {
+9
View File
@@ -59,6 +59,13 @@ type toolFailure struct {
// 因此流式文本、工具部件、用量与 pending 配对表全部在同一把锁下更新:
// 既不丢条目,也不会把 A 调用的 start 与 B 调用的 end 交错写坏。
// 事件发射与部件追加在同一临界区内,保持到达顺序与最终部件顺序一致。
//
// sink 契约(rc.Emit):同步、快、不得阻塞。onEvent 全程持 l.mu,发射在临界区内完成,
// 所以任何慢 sink 都会把所有过程帧串成一条队:并行工具的 goroutine 一起堵在锁上,
// 帧的到达顺序与最终部件顺序的不变式随之失去意义(且帧可能被重排)。既有 sink 只做
// 鉴权、投影与投递,不在回调里做重活;新增 sink 必须守住这条契约。真要做重活就放到
// 锁外(自建 goroutine 或交给 hub 排队),但那会放弃"发射与部件同序"这条不变式,
// 需要一并权衡,而不是顺手挪出来。
type squadEventLog struct {
rc RunCtx
@@ -76,6 +83,8 @@ func newSquadEventLog(rc RunCtx) *squadEventLog {
}
// onEvent 是小队过程帧回调(并发安全)。
//
// 锁内调 rc.Emit:sink 契约见 squadEventLog 的说明(同步、快、不得阻塞)。
func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
l.mu.Lock()
defer l.mu.Unlock()
+8
View File
@@ -140,6 +140,14 @@ func (s *Server) handleIntentCancel(w http.ResponseWriter, r *http.Request, conv
AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
})
result.Via, result.Accepted = "bus", resp.Receipt.State != agentcommand.StateRejected
if !result.Accepted {
// 拒绝原因如实透出(与本地投递路径对称):不受理就要说清为什么,
// 绝不只报一个「没成功」的空理由。
result.Reason = strings.TrimSpace(resp.Receipt.Reason)
if result.Reason == "" {
result.Reason = "命令被拒绝"
}
}
awaitingConfirm = resp.Card != nil
out["command"], out["receipt"] = intentCancelCommandID, resp.Receipt
if resp.Card != nil {
+58 -2
View File
@@ -10,6 +10,7 @@ package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
@@ -361,8 +362,9 @@ func TestIntentCancelRecordIsNotACancelTarget(t *testing.T) {
}
waitRunStatus(t, h, runID, string(core.RunCancelled))
gate.script("再取消一次", taskCancelIntent(nil))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "再取消一次"})
// 第二轮是占槽位的澄清回合:等上一轮被取消的运行彻底收工再发车(忙 409 是
// 既有语义,见 postConversationTurn)。
code, out := postConversationTurn(t, srv, h, st, convID, "再取消一次")
if code != 200 {
t.Fatalf("第二轮取消:%d %v", code, out)
}
@@ -397,6 +399,60 @@ func TestIntentCancelResolvesRunIDFromCommandValue(t *testing.T) {
waitRunStatus(t, h, runID, string(core.RunCancelled))
}
// 命令总线拒绝派发(处理器声明「确定未执行」):不受理,并如实给出拒绝
// 原因(与本地投递路径对称:绝不只报一个空理由)。
func TestIntentCancelRejectedDispatchReportsReason(t *testing.T) {
srv, st := newTestServer(t)
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: intentCancelCommandID,
Title: "取消运行", ParamsSchema: json.RawMessage(`{"type":"object","required":["text","run_id"],
"properties":{"run_id":{"type":"string"},"text":{"type":"string"}}}`)},
"", func(_ context.Context, req cmdbus.Request) (any, error) {
return nil, cmdbus.NotExecuted(errors.New("取消被策略拒绝"))
}); err != nil {
t.Fatal(err)
}
gate := &scriptedGate{}
srv.Gate = gate
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := liveRun(t, h, convID)
gate.script("取消刚才的任务", taskCancelIntent(map[string]string{"run_id": runID}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消刚才的任务"})
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
cancel, _ := out["cancel"].(map[string]any)
if cancel == nil {
t.Fatalf("取消回执缺失:%v", out)
}
if cancel["accepted"] != false || cancel["via"] != "bus" || cancel["runId"] != runID {
t.Fatalf("被拒绝的派发不得写成已受理:%v", cancel)
}
reason, _ := cancel["reason"].(string)
if !strings.Contains(reason, "取消被策略拒绝") {
t.Fatalf("拒绝原因必须如实透出:%v", cancel)
}
receipt, _ := out["receipt"].(map[string]any)
if receipt == nil || receipt["state"] != string(agentcommand.StateRejected) {
t.Fatalf("拒绝回执:%v", out)
}
// 没受理就不写会话记录,也不谎报已取消。
if _, noted := out["intent_session"]; noted {
t.Fatalf("被拒绝的取消不得写会话记录:%v", out)
}
if sess := pendingIntent(t, st, projectID, convID); sess != nil {
t.Fatalf("被拒绝的取消改动了会话:%+v", sess)
}
// 运行没被取消(拒绝即无副作用)。
if code, run := doJSON(t, h, "GET", "/api/runs/"+runID, nil); code == 200 {
if r, _ := run["run"].(map[string]any); r["status"] == string(core.RunCancelled) {
t.Fatalf("被拒绝的取消却取消了运行:%v", r)
}
}
}
// 消息原文进 params:取消命令与其它命令入口同款(恒带 text)。
func TestIntentCancelPassesOriginalText(t *testing.T) {
srv, _, gate, log := cancelServer(t)
+122 -22
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
@@ -15,6 +16,7 @@ import (
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// A1/U-A1b 会话语义的 chat 端到端(合成数据 + 现有 fakes):缺槽位动作先
@@ -113,6 +115,105 @@ func settle(t *testing.T, h http.Handler, out map[string]any) map[string]any {
return out
}
// 会话忙(409)是既有语义,不改:占槽位的回合(普通回合、澄清回合)在忙时如
// 实 409,意图命中的命令则不占槽位、会话忙时也能用。测试侧要解决的是「等发得
// 出去」,而不是放宽断言:上一轮的 run 行在结算提交那一刻就终态了,槽位要到收
// 尾(补锚点、发终态事件、摘执行句柄)之后才释放,机器繁忙时这段窗口会被放大,
// 于是「run 一终态就立刻发下一轮」会偶发撞上忙 409。
// postConversationTurn 发一轮消息:先等会话空闲(见 waitConversationIdle),
// 发车时若仍撞上忙(409)则在「忙拒没有留下痕迹」的前提下按 deadline 重发。
// 忙不写消息与运行,但抢槽位发生在意图会话折叠之后——被忙拒掉的轮次仍可能已经
// 消费或挂起了会话,再发一次走的就是另一条路径了;所以每次重发前先比对意图会
// 话状态:变过了就如实失败,没变过才重发。
func postConversationTurn(t *testing.T, srv *Server, h http.Handler, st *store.Store,
convID, text string) (int, map[string]any) {
t.Helper()
waitConversationIdle(t, srv, h, convID)
deadline := time.Now().Add(testRunTerminalTimeout)
for {
before := intentSessionTrace(t, st, convID)
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
if code != http.StatusConflict {
return code, out
}
if after := intentSessionTrace(t, st, convID); after != before {
t.Fatalf("%s:忙(409)之前已改动意图会话,重发会走另一条路径:%s → %s", text, before, after)
}
if !time.Now().Before(deadline) {
t.Fatalf("%s:会话持续忙(409)超过 %v:%v", text, testRunTerminalTimeout, out)
}
time.Sleep(20 * time.Millisecond)
}
}
// waitConversationIdle 等会话真正让出槽位:既没有未终态运行,也没有仍在本地
// 执行的 run——执行句柄在槽位释放之后才摘除(runmgr 的 execute 收尾),所以
// 「没有活跃句柄」就是槽位已空的可观测证据,不用赌收尾窗口有多宽。
func waitConversationIdle(t *testing.T, srv *Server, h http.Handler, convID string) {
t.Helper()
deadline := time.Now().Add(testRunTerminalTimeout)
for time.Now().Before(deadline) {
if code, out := doJSON(t, h, "GET", "/api/conversations/"+convID, nil); code == 200 &&
!conversationHasOpenRun(out) && !conversationHasActiveRun(srv, out) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("会话 %s 在 %v 内未空闲", convID, testRunTerminalTimeout)
}
// conversationHasOpenRun 看会话里是否还有未终态运行(结算尚未提交)。
func conversationHasOpenRun(detail map[string]any) bool {
for _, run := range conversationRuns(detail) {
switch run["status"] {
case string(core.RunCompleted), string(core.RunFailed),
string(core.RunCancelled), string(core.RunInterrupted):
default:
return true
}
}
return false
}
// conversationHasActiveRun 看会话里是否还有本地在执行的 run(槽位未让出)。
func conversationHasActiveRun(srv *Server, detail map[string]any) bool {
for _, run := range conversationRuns(detail) {
if id, _ := run["id"].(string); id != "" && srv.mgr.RunActiveLocally(id) {
return true
}
}
return false
}
func conversationRuns(detail map[string]any) []map[string]any {
raw, _ := detail["runs"].([]any)
runs := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if run, ok := item.(map[string]any); ok {
runs = append(runs, run)
}
}
return runs
}
// intentSessionTrace 描述会话上当前的意图会话状态(忙拒前后各取一次比对)。
func intentSessionTrace(t *testing.T, st *store.Store, convID string) string {
t.Helper()
conv, err := st.GetConversation(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
sess, err := st.GetIntentSession(store.OwnerDefault, string(conv.ProjectID), convID)
if err == store.ErrNotFound {
return "none"
}
if err != nil {
t.Fatal(err)
}
return fmt.Sprintf("rev=%d exp=%v slots=%v", sess.Revision, sess.Expectation, sess.Slots)
}
// 缺必填槽位 → 澄清并挂起;下一轮补齐 → 按同一条准入与派发路径执行。
func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
srv, st, gate, log := cancelServer(t)
@@ -122,7 +223,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: runIDQ}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -145,7 +246,7 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 下一轮:仅补齐槽位(库层挂起预期命中分支的同形结果)。
answerText := "编号:R-7 原因:误触发"
gate.script(answerText, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answerText})
code, out = postConversationTurn(t, srv, h, st, convID, answerText)
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发同一动作:%d %v", code, out)
}
@@ -167,12 +268,12 @@ func TestIntentSessionAnswerCompletesPendingAction(t *testing.T) {
// 两轮部分填充:先 set_slot 落一个槽位(仍缺 → 重新澄清),再由回答补齐。
func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
srv, _, gate, log := cancelServer(t)
srv, st, gate, log := cancelServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -181,7 +282,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第二轮:库层 set_slot(只带一个槽位),仍缺原因 → 如实重新澄清。
gate.script("编号 R-7", intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandSetSlot, Name: "run_id", Value: "R-7"}}})
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号 R-7"})
code, out = postConversationTurn(t, srv, h, st, convID, "编号 R-7")
if code != 200 {
t.Fatalf("set_slot 轮:%d %v", code, out)
}
@@ -203,7 +304,7 @@ func TestIntentSessionSetSlotThenAnswerCompletes(t *testing.T) {
// 第三轮:补上原因 → 派发,两个槽位都来自会话累计。
gate.script("原因:误触发", cancelAnswer(map[string]string{"reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "原因:误触发"})
code, out = postConversationTurn(t, srv, h, st, convID, "原因:误触发")
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发:%d %v", code, out)
}
@@ -220,7 +321,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -228,7 +329,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
gate.script("算了不取消了", intentgate.MatchResult{Branch: "matched", ID: "demo.run-cancel",
Commands: []intentgate.Command{{Kind: intentgate.CommandCancel, Target: "demo.run-cancel"}}})
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "算了不取消了"})
code, out = postConversationTurn(t, srv, h, st, convID, "算了不取消了")
if code != 200 {
t.Fatalf("取消轮:%d %v", code, out)
}
@@ -253,7 +354,7 @@ func TestIntentSessionCancelClearsPendingAction(t *testing.T) {
// 取消之后的回答不得补齐旧动作(这里识别仍不完整 → 只澄清,不派发)。
gate.script("编号:R-7", cancelAnswer(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号:R-7"})
code, out = postConversationTurn(t, srv, h, st, convID, "编号:R-7")
if code != 200 {
t.Fatalf("取消后回答轮:%d %v", code, out)
}
@@ -275,8 +376,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第一轮:抽到 run_id,仍缺原因 → 挂起(累计槽位含 run_id)。
gate.script("取消运行 编号:R-7", cancelStart(map[string]string{"run_id": "R-7"},
[]intentgate.SlotQuestion{{Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
map[string]any{"text": "取消运行 编号:R-7"})
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行 编号:R-7")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -297,7 +397,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
// 第二轮只补了原因:过期的 run_id 不得被借来凑齐动作。
answerText := "原因:误触发"
gate.script(answerText, cancelStart(map[string]string{"reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answerText})
code, out = postConversationTurn(t, srv, h, st, convID, answerText)
if code != 200 {
t.Fatalf("过期后的回答轮:%d %v", code, out)
}
@@ -339,7 +439,7 @@ func TestIntentSessionExpiryDoesNotCompleteOldAction(t *testing.T) {
t.Fatal(err)
}
gate.script("清理缓存", otherIntentStart("demo.purge"))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "清理缓存"})
code, out = postConversationTurn(t, srv, h, st, convID, "清理缓存")
if code != 200 || out["command"] != "demo.purge" {
t.Fatalf("新任务应照常派发:%d %v", code, out)
}
@@ -363,7 +463,7 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
projectB, convB := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convA+"/messages", map[string]any{"text": "取消运行"})
code, out := postConversationTurn(t, srv, h, st, convA, "取消运行")
if code != 200 {
t.Fatalf("A 会话第一轮:%d %v", code, out)
}
@@ -378,7 +478,7 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
// B 会话收到同样的回答:A 的槽位不得被借走(没有 A 的累计值)。
answer := "编号:R-7 原因:误触发"
gate.script(answer, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convB+"/messages", map[string]any{"text": answer})
code, out = postConversationTurn(t, srv, h, st, convB, answer)
if code != 200 {
t.Fatalf("B 会话回答轮:%d %v", code, out)
}
@@ -395,19 +495,19 @@ func TestIntentSessionIsolatesConversations(t *testing.T) {
func TestIntentSessionStillRequiresHighRiskConfirmation(t *testing.T) {
risky := cancelSpec
risky.Danger = agentcommand.DangerHigh
srv, _, gate, log := cancelServerWith(t, risky)
srv, st, gate, log := cancelServerWith(t, risky)
h := srv.Handler()
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}, {Key: "reason", Question: "取消原因?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
settle(t, h, out)
answer := "编号:R-7 原因:误触发"
gate.script(answer, cancelAnswer(map[string]string{"run_id": "R-7", "reason": "误触发"}, nil))
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": answer})
code, out = postConversationTurn(t, srv, h, st, convID, answer)
if code != 200 || out["command"] != "demo.run-cancel" {
t.Fatalf("补齐后应派发到总线:%d %v", code, out)
}
@@ -426,7 +526,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
_, convID := setupConversation(t, h)
gate.script("取消运行", cancelStart(nil, []intentgate.SlotQuestion{
{Key: "run_id", Question: "取消哪个运行?"}}))
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "取消运行"})
code, out := postConversationTurn(t, srv, h, st, convID, "取消运行")
if code != 200 {
t.Fatalf("第一轮:%d %v", code, out)
}
@@ -435,7 +535,7 @@ func TestIntentSessionInvalidAnswerKeepsClarifying(t *testing.T) {
gate.script("编号:R-9", intentgate.MatchResult{Branch: "expectation_hit", ID: "demo.run-cancel",
Slots: map[string]string{"run_id": "R-9"},
Invalid: []intentgate.SlotDefect{{Key: "run_id", Reason: "unknown run", Question: "取消哪个运行?", Invalid: true}}})
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "编号:R-9"})
code, out = postConversationTurn(t, srv, h, st, convID, "编号:R-9")
if code != 200 {
t.Fatalf("非法补充轮:%d %v", code, out)
}
@@ -495,7 +595,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
}
run := func(t *testing.T, sessions IntentSessions) []string {
t.Helper()
srv, _, gate, log := cancelServer(t)
srv, st, gate, log := cancelServer(t)
if sessions != nil {
srv.SetIntentSessionService(sessions)
}
@@ -504,7 +604,7 @@ func TestIntentSessionLegacyResponsesUnchanged(t *testing.T) {
_, convID := setupConversation(t, h)
var summaries []string
for _, text := range []string{"取消运行 编号:R-3 原因:误触发", "今天天气如何", "算了", "取消运行"} {
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
code, out := postConversationTurn(t, srv, h, st, convID, text)
if code != 200 {
t.Fatalf("%s:%d %v", text, code, out)
}
+32
View File
@@ -0,0 +1,32 @@
package server
import (
"net/http"
"time"
"agent-system-backend/internal/rundiag"
)
// getRunDiagnostics 是单次 run 的只读诊断入口(feature unit A2):
//
// GET /api/runs/{id}/diagnostics
//
// 它回答值班/排障最常问的六个问题:这个 run 卡在哪(responsibility.state)、
// 谁该动(responsibility.role)、为什么在等(responsibility.wait_reason)、最后一个
// 稳定落盘点(last_checkpoint)、已知的外部副作用(side_effects)、下一步动作
// (next_step)。
//
// 授权口径与既有 run 读入口逐字同款:owner 只取自认证上下文,绝不接受请求参数;
// run 由 store.GetRun(owner, id) 做租户过滤,跨租户与不存在同为 404(不泄露存在性)。
// handler 路径上只有 SELECT——不给写操作、不改状态、不触发任何副作用;聚合逻辑在
// internal/rundiag,证据缺失一律降级为 unknown + notes,不编造检查点,也不把"没有
// 记录"读成"没有发生"。
func (s *Server) getRunDiagnostics(w http.ResponseWriter, r *http.Request) {
run, err := s.st.GetRun(s.owner(r), r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
evidence := rundiag.Gather(s.st, run, time.Now().UTC())
writeJSON(w, http.StatusOK, rundiag.Build(evidence))
}
+226
View File
@@ -0,0 +1,226 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// diagnosticsFixture 建一个 owner 名下的项目/会话/run(合成数据,无执行器)。
func diagnosticsFixture(t *testing.T, st *store.Store, owner string, status core.RunStatus) (core.Run, core.Conversation) {
t.Helper()
project, err := st.CreateProject(owner, "诊断项目")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation(owner, project.ID, "诊断会话", "")
if err != nil {
t.Fatal(err)
}
run := core.Run{
ConversationID: conversation.ID, ProjectID: project.ID, Owner: owner,
Kind: core.RunConversation, Status: status, Phase: core.PhaseActing, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
return run, conversation
}
func getDiagnostics(t *testing.T, h http.Handler, key, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func decodeReport(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("diagnostics: %d %s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode diagnostics: %v (%s)", err, rec.Body.String())
}
return out
}
// 授权口径与既有 run 读入口同款:未认证 401、跨 owner 404(不泄露存在性)、
// 不存在的 run 同样 404。
func TestRunDiagnosticsOwnerScopingMatchesRunReads(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
}
run, _ := diagnosticsFixture(t, st, "alice", core.RunRunning)
path := "/api/runs/" + string(run.ID) + "/diagnostics"
if rec := getDiagnostics(t, h, "", path); rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated diagnostics must be rejected: %d", rec.Code)
}
if rec := getDiagnostics(t, h, bobKey, path); rec.Code != http.StatusNotFound {
t.Fatalf("cross-owner diagnostics must not leak the payload: %d %s", rec.Code, rec.Body.String())
}
if rec := getDiagnostics(t, h, aliceKey, "/api/runs/run_missing/diagnostics"); rec.Code != http.StatusNotFound {
t.Fatalf("missing run must be 404: %d", rec.Code)
}
rec := getDiagnostics(t, h, aliceKey, path)
report := decodeReport(t, rec)
runSection, _ := report["run"].(map[string]any)
if runSection["id"] != string(run.ID) || runSection["owner"] != "alice" || runSection["project"] != string(run.ProjectID) {
t.Fatalf("run identity wrong: %v", runSection)
}
responsibility, _ := report["responsibility"].(map[string]any)
if responsibility["state"] != "running" {
t.Fatalf("a running run with no blockers reads as running: %v", responsibility)
}
}
// 隔离中的 run:责任在副作用责任人,下一步是解除隔离;检查点与已记录的命令
// 回执一并给出。
func TestRunDiagnosticsReportsHoldCheckpointAndSideEffects(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
run, conversation := diagnosticsFixture(t, st, "alice", core.RunRunning)
if _, err := st.AppendEvents(string(conversation.ID), []core.StreamEvent{
{RunID: run.ID, Type: core.EvThinkingDelta, Payload: json.RawMessage(`{"index":0,"text":"核对差异"}`)},
{RunID: run.ID, Type: core.EvAgentProgress, Payload: json.RawMessage(`{"note":"核对中"}`)},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{
Time: time.Now().UTC(), Actor: "alice", CommandID: "rdm.docs.instantiate",
OperationID: "op_diag_1", ConversationID: string(conversation.ID), State: agentcommand.StateUnknown,
}); err != nil {
t.Fatal(err)
}
if err := st.SetRecoveryHold(string(run.ID), "alice", string(conversation.ID), "外部副作用结果未知"); err != nil {
t.Fatal(err)
}
report := decodeReport(t, getDiagnostics(t, h, key, "/api/runs/"+string(run.ID)+"/diagnostics"))
responsibility, _ := report["responsibility"].(map[string]any)
if responsibility["state"] != "recovery_hold" || responsibility["role"] != "operation-owner" {
t.Fatalf("held run responsibility: %v", responsibility)
}
nextStep, _ := report["next_step"].(map[string]any)
if nextStep["action"] != "release_recovery_hold" {
t.Fatalf("held run next step: %v", nextStep)
}
checkpoint, _ := report["last_checkpoint"].(map[string]any)
if checkpoint == nil || checkpoint["type"] != string(core.EvAgentProgress) {
t.Fatalf("last persisted checkpoint must be surfaced: %v", report["last_checkpoint"])
}
effects, _ := report["side_effects"].([]any)
var foundOperation bool
for _, raw := range effects {
effect, _ := raw.(map[string]any)
if effect["kind"] == "operation" && effect["operation_id"] == "op_diag_1" {
foundOperation = true
if effect["command_id"] != "rdm.docs.instantiate" || effect["state"] != string(agentcommand.StateUnknown) {
t.Fatalf("operation projection: %v", effect)
}
}
}
if !foundOperation {
t.Fatalf("recorded operation missing from side effects: %v", effects)
}
}
// 只读契约:GET 不改任何状态,写方法不注册。
func TestRunDiagnosticsIsReadOnly(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
run, conversation := diagnosticsFixture(t, st, "alice", core.RunRunning)
if err := st.SetRecoveryHold(string(run.ID), "alice", string(conversation.ID), "unknown"); err != nil {
t.Fatal(err)
}
before, err := st.GetRun("alice", string(run.ID))
if err != nil {
t.Fatal(err)
}
path := "/api/runs/" + string(run.ID) + "/diagnostics"
if rec := getDiagnostics(t, h, key, path); rec.Code != http.StatusOK {
t.Fatalf("diagnostics: %d %s", rec.Code, rec.Body.String())
}
after, err := st.GetRun("alice", string(run.ID))
if err != nil {
t.Fatal(err)
}
if before.Status != after.Status || before.Phase != after.Phase || !before.CreatedAt.Equal(after.CreatedAt) {
t.Fatalf("read-only endpoint mutated the run: %+v -> %+v", before, after)
}
hold, err := st.GetRecoveryHold(string(run.ID))
if err != nil || hold.ReleasedAt != "" {
t.Fatalf("read-only endpoint changed the recovery hold: %+v err=%v", hold, err)
}
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusOK || rec.Code == http.StatusAccepted {
t.Fatalf("%s must not be served on a read-only diagnostics path: %d", method, rec.Code)
}
}
}
// 读不到的证据降级为 unknown + note,不让整个诊断失败,也不给出安全重试。
func TestRunDiagnosticsUnknownStateDegradesWithNote(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
if err != nil {
t.Fatal(err)
}
run, _ := diagnosticsFixture(t, st, "alice", core.RunCancelled)
report := decodeReport(t, getDiagnostics(t, h, key, "/api/runs/"+string(run.ID)+"/diagnostics"))
responsibility, _ := report["responsibility"].(map[string]any)
if responsibility["state"] != "unknown" {
t.Fatalf("unmapped status must degrade to unknown: %v", responsibility)
}
if report["last_checkpoint"] != nil {
t.Fatalf("no persisted event means no checkpoint reference: %v", report["last_checkpoint"])
}
notes, _ := report["notes"].([]any)
var sawStatusNote, sawEffectNote bool
for _, raw := range notes {
note, _ := raw.(string)
if strings.Contains(note, "no diagnostic mapping") {
sawStatusNote = true
}
if strings.Contains(note, "absence of records is not proof") {
sawEffectNote = true
}
}
if !sawStatusNote || !sawEffectNote {
t.Fatalf("missing degradation notes: %v", notes)
}
}
+2
View File
@@ -392,6 +392,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/prompt-commands/{id}", s.deleteUserPromptCommand)
mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun)
mux.HandleFunc("GET /api/runs/{id}", s.getRun)
// 只读运行诊断(A2):卡在哪、谁负责、已知副作用、下一步;无写操作。
mux.HandleFunc("GET /api/runs/{id}/diagnostics", s.getRunDiagnostics)
// ---- HITL ----
mux.HandleFunc("POST /api/hitl/{id}/resolve", s.resolveHITL)
+19 -5
View File
@@ -8,8 +8,9 @@ package store
//
// 键是 (owner, project_id, conversation_id):两个用户、两个项目的会话在
// 构造上互不可见,读写一律带 owner 过滤。行是运行态(默认 30 分钟后
// 过期、读侧惰性删除),不进 storage-migrate 的 copyOrder(同
// admission_leases:进程/会话瞬态,迁移没有意义)。
// 过期、读侧惰性删除);但它随库迁移:v52 起 intent_session 已登记在
// storage-migrate 的 copyOrder 里(夹具即文档:新表漏登记会被那条用例
// 挡住),源库没有这张表时由 sqliteHasTable 跳过(旧库无此表)。
import (
"database/sql"
@@ -46,8 +47,12 @@ type IntentSession struct {
Slots map[string]string
Revision int
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
// ExpiresValid 报告到期时间是否可用:库里的 expires_at 为空或不可解析
// 时为假(此时 ExpiresAt 不可信)。宿主据此 fail-closed 把该行当成
// 已过期,绝不把损坏的到期时间当成「永不过期」。
ExpiresValid bool
CreatedAt time.Time
UpdatedAt time.Time
}
// GetIntentSession 按 owner+project+conversation 读回意图会话。
@@ -159,7 +164,16 @@ func scanIntentSession(owner, projectID, conversationID string, row *sql.Row) (I
out.Slots = slots
}
}
out.ExpiresAt, _ = parseTime(expiresAt)
// 到期时间:可解析才算有效。空值(没有 TTL)或解析失败(行被写坏)都
// 如实标成不可用——宿主据此 fail-closed 当作已过期,绝不 fail-open 地
// 当成「永不过期」。
expires, err := parseTime(strings.TrimSpace(expiresAt))
if err != nil {
out.ExpiresValid = false
} else {
out.ExpiresValid = true
}
out.ExpiresAt = expires
out.CreatedAt, _ = parseTime(createdAt)
out.UpdatedAt, _ = parseTime(updatedAt)
return out, nil
+34
View File
@@ -120,6 +120,40 @@ func TestIntentSessionIsolatesOwnersAndProjects(t *testing.T) {
}
}
// 到期时间:可解析才算有效。为空(没有 TTL)或被写坏都如实标成「不可用」,
// 宿主据此 fail-closed 当作已过期——绝不当成「永不过期」。
func TestIntentSessionExpiryIsReportedUnusableWhenCorrupted(t *testing.T) {
st := openTestStore(t)
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_1", cancelExpectation(),
map[string]string{"run_id": "R-1"}, time.Minute); err != nil {
t.Fatal(err)
}
got, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
if err != nil || !got.ExpiresValid || got.ExpiresAt.IsZero() {
t.Fatalf("正常写入的到期时间应可用:%+v %v", got, err)
}
// 直接改坏库里的 expires_at(模拟损坏的行)。
if _, err := st.DB().Exec(st.q(`UPDATE intent_session SET expires_at = ? WHERE owner = ? AND conversation_id = ?`),
"not-a-timestamp", "alice", "cnv_1"); err != nil {
t.Fatal(err)
}
broken, err := st.GetIntentSession("alice", "prj_1", "cnv_1")
if err != nil {
t.Fatal(err)
}
if broken.ExpiresValid || !broken.ExpiresAt.IsZero() {
t.Fatalf("损坏的到期时间必须如实标成不可用:%+v", broken)
}
// 没有 TTL(ttl<=0)的写入同样没有可用到期时间。
if _, err := st.PutIntentSession("alice", "prj_1", "cnv_2", cancelExpectation(), nil, 0); err != nil {
t.Fatal(err)
}
none, err := st.GetIntentSession("alice", "prj_1", "cnv_2")
if err != nil || none.ExpiresValid {
t.Fatalf("没有到期时间的行不得被当成有效:%+v %v", none, err)
}
}
// intentSessionTableExists 双档目录探测(SQLite sqlite_master / PG information_schema)。
func intentSessionTableExists(t *testing.T, st *Store) bool {
t.Helper()
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"database/sql"
"errors"
"time"
)
// 运行诊断的只读投影(feature unit A2)。本文件只放 SELECT:不加表、不加索引、
// 不动迁移,也不引入新的写语义。诊断面回答的是"已经落库的证据是什么",因此
// 每个查询都遵守同一条口径:查不到就是"没有记录",调用方不得把它读成"没发生"
// (与 /api/commands/operations 的对账查询同源,见 cmdbus.OperationQuery)。
// RunCheckpoint 是 run 事件流上最后一个已持久化事件的稳定引用。
//
// 诚实边界:runs 行本身没有"检查点"列——事件流(events)才是 run 唯一的持久
// 进度痕迹(续跑、SSE 重放都以它为准)。所以这里给的是"最后一次稳定落盘"的
// 定位点(seq 可作游标),不是推断出来的中间状态;run 没有任何事件时不存在这条
// 记录,调用方必须显式处理"没有"而不是编一个。
type RunCheckpoint struct {
Seq int64 // 会话事件序(可作重放游标)
TS string // RFC3339(写入方原样落库)
Type string // 事件类型
}
// LastRunCheckpoint 返回该 run 在会话事件流里的最后一条已持久化事件。
// 没有事件时 ok=false,err 为 nil。
func (s *Store) LastRunCheckpoint(conversationID, runID string) (RunCheckpoint, bool, error) {
if conversationID == "" || runID == "" {
return RunCheckpoint{}, false, nil
}
var cp RunCheckpoint
err := s.db.QueryRow(s.q(`SELECT seq, ts, type FROM events
WHERE conversation_id=? AND run_id=? ORDER BY seq DESC LIMIT 1`), conversationID, runID).
Scan(&cp.Seq, &cp.TS, &cp.Type)
if errors.Is(err, sql.ErrNoRows) {
return RunCheckpoint{}, false, nil
}
if err != nil {
return RunCheckpoint{}, false, err
}
return cp, true, nil
}
// PendingHumanDecisionsByConversation 列出会话内未过期、尚未裁决的人工决策卡
// (owner + 会话双谓词,跨租户天然不可见;过期卡按 PendingHumanDecisions 的同一
// 口径过滤)。诊断面据此判断 run 是否在等人工裁决。
//
// 口径说明:决策卡按命令与会话归属,表里没有 run 列——把它归到某个 run 是调用方
// 的时间窗推断,不是存储事实(见 rundiag 的归属边界注释)。
func (s *Store) PendingHumanDecisionsByConversation(owner, conversationID string, now time.Time) ([]HumanDecision, error) {
if owner == "" || conversationID == "" {
return []HumanDecision{}, nil
}
rows, err := s.db.Query(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,
proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at
FROM human_decisions WHERE owner=? AND conversation_id=? AND status='pending' ORDER BY issued_at`),
owner, conversationID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []HumanDecision{}
for rows.Next() {
rec, err := scanHumanDecision(rows)
if err != nil {
return nil, err
}
if now.Before(rec.ExpiresAt) {
out = append(out, rec)
}
}
return out, rows.Err()
}
// PendingConfirm 是危险命令确认挂起态(pending_confirms)的只读行。
// Payload 是 cmdbus 落下的分发请求 JSON(确认后据此二次分发);诊断面只解析出
// 归属与命令标识,不回显原始载荷。
type PendingConfirm struct {
CardID string
Owner string
Payload string
CreatedAt string
}
// ListOwnerPendingConfirms 列出该租户名下尚未确认的危险命令挂起行(时间正序)。
// owner 为空视为无作用域:不返回任何行(纵深防御,认证层恒注入非空 owner)。
func (s *Store) ListOwnerPendingConfirms(owner string, limit int) ([]PendingConfirm, error) {
if owner == "" {
return []PendingConfirm{}, nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(s.q(`SELECT card_id, owner, payload, created_at FROM pending_confirms
WHERE owner=? ORDER BY created_at LIMIT ?`), owner, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []PendingConfirm{}
for rows.Next() {
var p PendingConfirm
if err := rows.Scan(&p.CardID, &p.Owner, &p.Payload, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"encoding/json"
"testing"
"time"
core "agent-system-core"
)
// 运行诊断只读投影:检查点定位、HITL 决策卡的会话+租户作用域、确认挂起行的租户
// 作用域。口径与既有读入口一致:查不到就是"没有记录",不推断"没发生"。
func TestRunDiagnosticsReadHelpers(t *testing.T) {
st := openTestStore(t)
base := time.Now().UTC().Truncate(time.Second)
if err := st.EnsureUser("alice", ""); err != nil {
t.Fatal(err)
}
if err := st.EnsureUser("bob", ""); err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("alice", "诊断项目")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("alice", project.ID, "诊断会话", "")
if err != nil {
t.Fatal(err)
}
run := core.Run{ConversationID: conversation.ID, ProjectID: project.ID, Owner: "alice",
Kind: core.RunConversation, Status: core.RunRunning, CreatedAt: base}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
other := core.Run{ConversationID: conversation.ID, ProjectID: project.ID, Owner: "alice",
Kind: core.RunSubagent, Status: core.RunRunning, CreatedAt: base}
if err := st.CreateRun(&other); err != nil {
t.Fatal(err)
}
// 检查点:只认本 run 的最后一条事件,且不跨 run 串味。
if cp, ok, err := st.LastRunCheckpoint(string(conversation.ID), string(run.ID)); err != nil || ok {
t.Fatalf("a run without events has no checkpoint: %+v ok=%v err=%v", cp, ok, err)
}
if _, err := st.AppendEvents(string(conversation.ID), []core.StreamEvent{
{RunID: run.ID, Type: core.EvThinkingDelta, TS: base.Format(time.RFC3339), Payload: json.RawMessage(`{}`)},
{RunID: other.ID, Type: core.EvToolOutput, TS: base.Format(time.RFC3339), Payload: json.RawMessage(`{}`)},
{RunID: run.ID, Type: core.EvAgentProgress, TS: base.Format(time.RFC3339), Payload: json.RawMessage(`{}`)},
}); err != nil {
t.Fatal(err)
}
cp, ok, err := st.LastRunCheckpoint(string(conversation.ID), string(run.ID))
if err != nil || !ok {
t.Fatalf("checkpoint read: ok=%v err=%v", ok, err)
}
if cp.Type != string(core.EvAgentProgress) || cp.TS == "" || cp.Seq <= 0 {
t.Fatalf("checkpoint must point at the newest persisted event of this run: %+v", cp)
}
if child, ok, err := st.LastRunCheckpoint(string(conversation.ID), string(other.ID)); err != nil || !ok || child.Type != string(core.EvToolOutput) {
t.Fatalf("child run checkpoint: %+v ok=%v err=%v", child, ok, err)
}
if cp, ok, err := st.LastRunCheckpoint("cnv_other", string(run.ID)); err != nil || ok {
t.Fatalf("another conversation must not expose this run's events: %+v ok=%v err=%v", cp, ok, err)
}
// HITL 决策卡:pending + 未过期才可见,且按 owner+会话双谓词隔离。
now := base.Add(time.Minute)
issue := func(id, owner, conversationID string, ttl time.Duration) {
t.Helper()
if _, err := st.IssueHumanDecision(HumanDecision{PluginID: "p", Audience: "a", CommandID: "rdm.confirm",
HumanActor: owner, Owner: owner, ProjectID: string(project.ID), ProposalID: id, ProposalRevision: 1,
PayloadHash: "h" + id, Question: "approve " + id + "?", ConversationID: conversationID},
now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
// 过期口径:把 expires_at 改到过去,模拟签发即过期的卡(走方言绑定)。
if ttl < 0 {
if _, err := st.DB().Exec(st.q(`UPDATE human_decisions SET expires_at=? WHERE proposal_id=?`),
now.Add(-time.Hour).Format(time.RFC3339Nano), id); err != nil {
t.Fatal(err)
}
}
}
issue("alive", "alice", string(conversation.ID), time.Hour)
issue("expired", "alice", string(conversation.ID), -time.Hour)
issue("otherowner", "bob", "cnv_bob", time.Hour)
pending, err := st.PendingHumanDecisionsByConversation("alice", string(conversation.ID), now)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].ProposalID != "alive" || pending[0].Status != "pending" {
t.Fatalf("only live pending cards in scope: %+v", pending)
}
if cross, err := st.PendingHumanDecisionsByConversation("bob", string(conversation.ID), now); err != nil || len(cross) != 0 {
t.Fatalf("cross-owner cards must stay invisible: %+v err=%v", cross, err)
}
if none, err := st.PendingHumanDecisionsByConversation("", string(conversation.ID), now); err != nil || len(none) != 0 {
t.Fatalf("an empty owner has no scope: %+v err=%v", none, err)
}
// 确认挂起行:按租户隔离,空 owner 无作用域。
if err := st.SavePendingConfirm("card_alice", "alice", `{"ID":"rdm.confirm"}`); err != nil {
t.Fatal(err)
}
if err := st.SavePendingConfirm("card_bob", "bob", `{"ID":"rdm.confirm"}`); err != nil {
t.Fatal(err)
}
confirms, err := st.ListOwnerPendingConfirms("alice", 0)
if err != nil {
t.Fatal(err)
}
if len(confirms) != 1 || confirms[0].CardID != "card_alice" || confirms[0].Owner != "alice" || confirms[0].Payload == "" || confirms[0].CreatedAt == "" {
t.Fatalf("owner-scoped pending confirms: %+v", confirms)
}
if none, err := st.ListOwnerPendingConfirms("", 0); err != nil || len(none) != 0 {
t.Fatalf("an empty owner has no scope: %+v err=%v", none, err)
}
}