revert: remove cross-agent worker direction synchronization
This commit is contained in:
1 parent
7e633b2056
commit
76210b9933
9 files changed
+11
-425
No files matched your search
+1
-23
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
@@ -86,27 +85,6 @@ func mainAgentSystem(goal, dataDir, workDir string) string {
|
||||
return body + artifactSpec(workDir)
|
||||
}
|
||||
|
||||
// mainAgentInput keeps Worker-side human steering visible to the Main Agent on
|
||||
// every fresh Chat session. The direction records remain separate from the
|
||||
// current user turn so the model does not mistake persisted context for a new
|
||||
// instruction that must be sent to the Worker again.
|
||||
func mainAgentInput(ts *db.ExplorationStore, message string) string {
|
||||
if ts == nil {
|
||||
return message
|
||||
}
|
||||
directions, err := ts.LatestWorkerDirections(workerDirectionOverviewLimit)
|
||||
if err != nil || len(directions) == 0 {
|
||||
return message
|
||||
}
|
||||
raw, err := json.Marshal(compactWorkerDirections(directions))
|
||||
if err != nil {
|
||||
return message
|
||||
}
|
||||
return "【系统同步的 Worker 最新人工方向】\n" +
|
||||
"以下内容来自持久化的 Worker 对话。回答、观察和调度时必须纳入,但不要把它当作本轮新消息重复下发。\n" +
|
||||
string(raw) + "\n\n【用户本轮消息】\n" + message
|
||||
}
|
||||
|
||||
// Chat handles one human message and returns the assistant reply. emit, if
|
||||
// non-nil, receives each execution step (thinking / tool_use / tool_result /
|
||||
// text / result) so the main-agent session shows its work — exactly like the
|
||||
@@ -175,7 +153,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
// C2: this session is fresh each turn; re-unlock skill-gated MCPs from prior
|
||||
// Skill() calls in the reloaded history so revealed tools stay callable.
|
||||
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
||||
text, _, err := captureRunSession(ctx, s, mainAgentInput(ts, message), func(r db.Activity) {
|
||||
text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) {
|
||||
if emit != nil {
|
||||
r.Worker = "mainagent"
|
||||
emit(r)
|
||||
|
||||
+6
-14
@@ -123,7 +123,6 @@ func renderPlannerTodos(items []actool.Todo) string {
|
||||
//
|
||||
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
|
||||
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
|
||||
// "worker_message" — the human changed one Worker's direction (Detail = 新方向).
|
||||
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
|
||||
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
|
||||
// "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本).
|
||||
@@ -131,17 +130,14 @@ func renderPlannerTodos(items []actool.Todo) string {
|
||||
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
|
||||
// stopped (not deleted) and the reason is attached to it as a fact.
|
||||
type TriggerEvent struct {
|
||||
Kind string
|
||||
ActivityID int64 // persistent activity id; worker_message notifications use it for de-duplication
|
||||
IntentID int64
|
||||
Detail string
|
||||
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
|
||||
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
|
||||
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
|
||||
Kind string
|
||||
IntentID int64
|
||||
Detail string
|
||||
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
|
||||
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
|
||||
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
|
||||
}
|
||||
|
||||
const TriggerWorkerMessage = db.PlannerEventWorkerMessage
|
||||
|
||||
// renderTriggers spells out the change(s) that fired this round: for a finished
|
||||
// worker — which intent + its output conclusion; for a finding — which intent +
|
||||
// what was found. Empty for time/heartbeat wakes. Reads the store (best-effort;
|
||||
@@ -166,9 +162,6 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
|
||||
b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal))
|
||||
case "finding":
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
|
||||
case TriggerWorkerMessage:
|
||||
b.WriteString(fmt.Sprintf("\n- 用户向意图 #%d(%s)的 Worker 下发了新的人工方向:%s —— 该方向已优先交给同一 Worker;后续规划必须以它替代或补充原意图描述,避免生成冲突、回退或重复方向。",
|
||||
ev.IntentID, intentSummary(ts, ev.IntentID), truncOutput(ev.Detail, 2000)))
|
||||
case "cancelled":
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已停止(不再执行),其原因已作为事实挂在该意图上;请据此重新规划。", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
|
||||
default: // "done"
|
||||
@@ -257,7 +250,6 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
|
||||
决策流程(每次唤醒):
|
||||
1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。
|
||||
- open_intents / running_intents / recent_done_intents——"哪些方向已经有意图在覆盖/已尝试"。每个意图还带 **parents(上游:它派生自哪些事实/意图)和 yields(下游:它产生了哪些事实/发现)**——这就是探索图的**血缘关系**,据此理解"哪些事实来自哪个方向、能否综合成新方向"。recent_facts 里每个事实带 **from_intent**(由哪个意图产生)。
|
||||
- worker_directions——用户在 Worker 对话里对每个意图最近一次下发的人工方向(持久化、重启后仍存在)。它比该意图最初的 summary 更新;判断在跑方向、补意图和使用 steer/kill 时必须以它为准,禁止重新派发与人工方向冲突的工作。
|
||||
- sites_without_endpoints / findings——"哪些方向【可能】需要探索"。
|
||||
- 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。
|
||||
2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。
|
||||
|
||||
@@ -48,38 +48,6 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
|
||||
return out
|
||||
}
|
||||
|
||||
const workerDirectionOverviewLimit = 300
|
||||
const workerDirectionMessageRunes = 1200
|
||||
|
||||
// compactWorkerDirections keeps the latest durable human direction for each
|
||||
// intent small enough for every Planner/Main Agent turn. The full message stays
|
||||
// available in the Worker activity trace when an unusually long direction is
|
||||
// truncated here.
|
||||
func compactWorkerDirections(items []db.WorkerDirection) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
message := item.Message
|
||||
truncated := false
|
||||
if runes := []rune(message); len(runes) > workerDirectionMessageRunes {
|
||||
message = string(runes[:workerDirectionMessageRunes]) + "…"
|
||||
truncated = true
|
||||
}
|
||||
row := map[string]any{
|
||||
"activity_id": item.ActivityID,
|
||||
"intent_id": item.IntentID,
|
||||
"intent_state": item.IntentState,
|
||||
"intent_summary": item.IntentSummary,
|
||||
"message": message,
|
||||
"updated_at": item.CreatedAt,
|
||||
}
|
||||
if truncated {
|
||||
row["truncated"] = true
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolSet exposes the PG-backed dual graph (asset + exploration) to an LLM agent.
|
||||
// One ToolSet is created per planner/worker run; per-run signals live here.
|
||||
type ToolSet struct {
|
||||
@@ -392,14 +360,6 @@ func (t *ToolSet) graphOverview() actool.CoreTool {
|
||||
// an empty context and always needs this first).
|
||||
func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
out := map[string]any{}
|
||||
// worker_directions is the persistent cross-agent steering channel. It has
|
||||
// one latest accepted human message per local intent, so Planner and Main
|
||||
// Agent do not have to infer a changed direction from the original summary.
|
||||
if directions, err := t.ts.LatestWorkerDirections(workerDirectionOverviewLimit); err == nil {
|
||||
out["worker_directions"] = compactWorkerDirections(directions)
|
||||
} else {
|
||||
out["worker_directions"] = []map[string]any{}
|
||||
}
|
||||
// goals summary folded in so the planner needn't call list_goals each round.
|
||||
goals, _ := t.ts.ListByKind(db.KindGoal, 100)
|
||||
gsum := make([]map[string]any, 0, len(goals))
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
type directionCaptureProvider struct {
|
||||
mu sync.Mutex
|
||||
requests []llm.CompletionRequest
|
||||
}
|
||||
|
||||
func (p *directionCaptureProvider) Stream(_ context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
p.mu.Lock()
|
||||
p.requests = append(p.requests, req)
|
||||
p.mu.Unlock()
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
if !yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "同步完成"}, nil) {
|
||||
return
|
||||
}
|
||||
if !yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil) {
|
||||
return
|
||||
}
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *directionCaptureProvider) Complete(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
p.mu.Lock()
|
||||
p.requests = append(p.requests, req)
|
||||
p.mu.Unlock()
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("同步完成")}}, "end_turn", llm.Usage{}, nil
|
||||
}
|
||||
|
||||
func (p *directionCaptureProvider) lastRequest(t *testing.T) llm.CompletionRequest {
|
||||
t.Helper()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.requests) == 0 {
|
||||
t.Fatal("provider received no completion request")
|
||||
}
|
||||
return p.requests[len(p.requests)-1]
|
||||
}
|
||||
|
||||
func lastUserText(t *testing.T, req llm.CompletionRequest) string {
|
||||
t.Helper()
|
||||
for i := len(req.Messages) - 1; i >= 0; i-- {
|
||||
if req.Messages[i].Role == llm.RoleUser {
|
||||
return req.Messages[i].Text()
|
||||
}
|
||||
}
|
||||
t.Fatal("completion request has no user message")
|
||||
return ""
|
||||
}
|
||||
|
||||
type directionAgentFixture struct {
|
||||
d *db.DB
|
||||
task *db.Task
|
||||
store *db.ExplorationStore
|
||||
intentID int64
|
||||
activity db.Activity
|
||||
}
|
||||
|
||||
func newDirectionAgentFixture(t *testing.T) *directionAgentFixture {
|
||||
t.Helper()
|
||||
d := testDB(t)
|
||||
task, err := d.CreateTask("agent direction sync", "synchronize worker steering", nil, 0, 0)
|
||||
if err != nil {
|
||||
d.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
_ = d.Close()
|
||||
})
|
||||
store := d.Exploration(task.ExplorationID)
|
||||
intentID, err := store.AddIntent(map[string]any{"summary": "原始接口枚举方向"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claimed, err := store.ClaimIntent(intentID, "work#direction"); err != nil || !claimed {
|
||||
t.Fatalf("claim direction fixture: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
const requestID = "agent-direction-sync-1"
|
||||
const direction = "停止接口枚举,立即聚焦登录后的水平越权"
|
||||
if _, err := store.ReserveIntentIntervention(intentID, requestID, direction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed {
|
||||
t.Fatalf("pause direction fixture: changed=%v err=%v", changed, err)
|
||||
}
|
||||
activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, direction)
|
||||
if err != nil || duplicate {
|
||||
t.Fatalf("accept direction fixture: activity=%+v duplicate=%v err=%v", activity, duplicate, err)
|
||||
}
|
||||
return &directionAgentFixture{d: d, task: task, store: store, intentID: intentID, activity: activity}
|
||||
}
|
||||
|
||||
func TestGraphOverviewIncludesLatestWorkerDirection(t *testing.T) {
|
||||
f := newDirectionAgentFixture(t)
|
||||
overview := NewToolSet(f.store, "planner").graphOverviewData()
|
||||
directions, ok := overview["worker_directions"].([]map[string]any)
|
||||
if !ok || len(directions) != 1 {
|
||||
t.Fatalf("worker_directions=%T %+v", overview["worker_directions"], overview["worker_directions"])
|
||||
}
|
||||
if directions[0]["activity_id"] != f.activity.ID || directions[0]["intent_id"] != f.intentID ||
|
||||
directions[0]["intent_summary"] != "原始接口枚举方向" ||
|
||||
directions[0]["message"] != "停止接口枚举,立即聚焦登录后的水平越权" {
|
||||
t.Fatalf("worker direction overview=%+v", directions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerRequestIncludesWorkerMessageTriggerAndPersistedDirection(t *testing.T) {
|
||||
f := newDirectionAgentFixture(t)
|
||||
provider := &directionCaptureProvider{}
|
||||
planner := NewPlanner(provider, "test-model", t.TempDir(), nil, 0, 1)
|
||||
triggerDetail := "本轮只验证登录后的对象级授权"
|
||||
_, _, err := planner.Plan(context.Background(), f.task.ID, nil, f.store, f.task.Goal, []TriggerEvent{{
|
||||
Kind: TriggerWorkerMessage, ActivityID: f.activity.ID, IntentID: f.intentID, Detail: triggerDetail,
|
||||
}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := lastUserText(t, provider.lastRequest(t))
|
||||
for _, want := range []string{"【本次触发本轮的实际变动】", triggerDetail, `"worker_directions"`, "停止接口枚举,立即聚焦登录后的水平越权"} {
|
||||
if !strings.Contains(input, want) {
|
||||
t.Fatalf("Planner input missing %q:\n%s", want, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainAgentNextTurnIncludesPersistedWorkerDirection(t *testing.T) {
|
||||
f := newDirectionAgentFixture(t)
|
||||
provider := &directionCaptureProvider{}
|
||||
mainAgent := NewMainAgent(provider, "test-model", t.TempDir(), nil, 0, 1)
|
||||
const userMessage = "汇报当前执行方向"
|
||||
if _, err := mainAgent.Chat(context.Background(), f.task.ID, nil, f.store, f.task.Goal, userMessage, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := lastUserText(t, provider.lastRequest(t))
|
||||
for _, want := range []string{"【系统同步的 Worker 最新人工方向】", "停止接口枚举,立即聚焦登录后的水平越权", "【用户本轮消息】", userMessage} {
|
||||
if !strings.Contains(input, want) {
|
||||
t.Fatalf("Main Agent input missing %q:\n%s", want, input)
|
||||
}
|
||||
}
|
||||
if strings.Index(input, "【系统同步的 Worker 最新人工方向】") > strings.Index(input, "【用户本轮消息】") {
|
||||
t.Fatalf("Main Agent context is not separated before current user message:\n%s", input)
|
||||
}
|
||||
}
|
||||
@@ -339,7 +339,6 @@ const (
|
||||
IntentInterventionPending = "pending"
|
||||
IntentInterventionAccepted = "accepted"
|
||||
IntentInterventionRejected = "rejected"
|
||||
PlannerEventWorkerMessage = "worker_message"
|
||||
)
|
||||
|
||||
// IntentIntervention is the durable control record for one human interruption.
|
||||
@@ -356,19 +355,6 @@ type IntentIntervention struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// WorkerDirection is the latest accepted human direction for one Worker
|
||||
// intent. The accepted activity is the durable planning event, so this view
|
||||
// survives restarts and is shared by Planner and Main Agent context builders.
|
||||
type WorkerDirection struct {
|
||||
ActivityID int64
|
||||
IntentID int64
|
||||
RequestID string
|
||||
Message string
|
||||
IntentState string
|
||||
IntentSummary string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// lockIntentIntervention serializes request-id reservation/finalization across
|
||||
// processes. exploration_nodes.id is a global primary key, so the intent id is
|
||||
// sufficient input to the namespaced advisory key.
|
||||
@@ -577,7 +563,6 @@ WHERE a.exploration_id=$1 AND a.node_id=$2
|
||||
"intervention_request_id": requestID,
|
||||
"intervention_message": message,
|
||||
"intervention_status": IntentInterventionAccepted,
|
||||
"planner_event": PlannerEventWorkerMessage,
|
||||
"source": "user",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -624,52 +609,6 @@ WHERE a.exploration_id=$1 AND a.node_id=$2
|
||||
return activity, false, nil
|
||||
}
|
||||
|
||||
// LatestWorkerDirections returns at most one accepted human direction per
|
||||
// local intent, newest directions first. It reads the visible user activity
|
||||
// rather than the hidden hand-off row so archive/restore and activity history
|
||||
// share one authoritative record.
|
||||
func (s *ExplorationStore) LatestWorkerDirections(limit int) ([]WorkerDirection, error) {
|
||||
if limit <= 0 {
|
||||
limit = 300
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT activity_id,intent_id,request_id,message,intent_state,intent_summary,created_at
|
||||
FROM (
|
||||
SELECT DISTINCT ON (a.node_id)
|
||||
a.id AS activity_id,
|
||||
a.node_id AS intent_id,
|
||||
COALESCE(a.metadata->>'intervention_request_id','') AS request_id,
|
||||
COALESCE(a.metadata->>'intervention_message',a.detail,'') AS message,
|
||||
n.state AS intent_state,
|
||||
COALESCE(n.payload->>'summary','') AS intent_summary,
|
||||
a.created_at
|
||||
FROM activity a
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1
|
||||
AND a.kind='user'
|
||||
AND n.kind='intent'
|
||||
AND a.metadata->>'intervention_status'=$2
|
||||
AND a.metadata->>'planner_event'=$3
|
||||
ORDER BY a.node_id,a.id DESC
|
||||
) latest
|
||||
ORDER BY activity_id DESC
|
||||
LIMIT $4`, s.expID, IntentInterventionAccepted, PlannerEventWorkerMessage, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]WorkerDirection, 0)
|
||||
for rows.Next() {
|
||||
var item WorkerDirection
|
||||
if err := rows.Scan(&item.ActivityID, &item.IntentID, &item.RequestID, &item.Message,
|
||||
&item.IntentState, &item.IntentSummary, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecoverableIntentInterventions returns control operations that must be
|
||||
// reconstructed before ordinary frontier claiming after a process restart:
|
||||
// pending transcript commits, plus accepted hand-offs not yet claimed once.
|
||||
|
||||
@@ -202,72 +202,3 @@ func TestIntentInterventionAcceptanceCanRetryAfterStateSettlement(t *testing.T)
|
||||
t.Fatalf("retry acceptance: activity=%+v duplicate=%v err=%v", activity, duplicate, err)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptTestWorkerDirection(t *testing.T, store *ExplorationStore, intentID int64, requestID, message string) Activity {
|
||||
t.Helper()
|
||||
if claimed, err := store.ClaimIntent(intentID, "direction-test-worker"); err != nil || !claimed {
|
||||
t.Fatalf("claim direction intent: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
if _, err := store.ReserveIntentIntervention(intentID, requestID, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed {
|
||||
t.Fatalf("pause direction intent: changed=%v err=%v", changed, err)
|
||||
}
|
||||
activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, message)
|
||||
if err != nil || duplicate {
|
||||
t.Fatalf("accept direction: activity=%+v duplicate=%v err=%v", activity, duplicate, err)
|
||||
}
|
||||
return activity
|
||||
}
|
||||
|
||||
func TestLatestWorkerDirectionsKeepsLatestPerIntentAcrossStoreReopen(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("worker direction persistence", "keep latest directions", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID) //nolint:errcheck
|
||||
store := d.Exploration(task.ExplorationID)
|
||||
firstIntent, err := store.AddIntent(map[string]any{"summary": "first route"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondIntent, err := store.AddIntent(map[string]any{"summary": "second route"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
acceptTestWorkerDirection(t, store, firstIntent, "direction-old", "先检查旧方向")
|
||||
secondActivity := acceptTestWorkerDirection(t, store, secondIntent, "direction-second", "保留第二条意图方向")
|
||||
latestActivity := acceptTestWorkerDirection(t, store, firstIntent, "direction-latest", "改为验证最新权限边界")
|
||||
|
||||
d2, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d2.Close()
|
||||
directions, err := d2.Exploration(task.ExplorationID).LatestWorkerDirections(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(directions) != 2 {
|
||||
t.Fatalf("directions=%+v, want one latest row per intent", directions)
|
||||
}
|
||||
byIntent := make(map[int64]WorkerDirection, len(directions))
|
||||
for _, direction := range directions {
|
||||
byIntent[direction.IntentID] = direction
|
||||
}
|
||||
if got := byIntent[firstIntent]; got.ActivityID != latestActivity.ID || got.RequestID != "direction-latest" ||
|
||||
got.Message != "改为验证最新权限边界" || got.IntentSummary != "first route" {
|
||||
t.Fatalf("latest first direction=%+v", got)
|
||||
}
|
||||
if got := byIntent[secondIntent]; got.ActivityID != secondActivity.ID || got.Message != "保留第二条意图方向" ||
|
||||
got.IntentSummary != "second route" {
|
||||
t.Fatalf("second direction=%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -687,7 +687,6 @@ func (e *Engine) interveneWork(ctx context.Context, t *Task, intentID int64, req
|
||||
}
|
||||
if !duplicate {
|
||||
e.publishStoredActivity(t, activity)
|
||||
t.NotifyWorkerMessage(intentID, activity.ID, message)
|
||||
}
|
||||
e.readyDirectedClaim(intentID)
|
||||
t.NotifyWorker()
|
||||
@@ -752,10 +751,6 @@ func (e *Engine) recoverWorkerMessages(ctx context.Context, t *Task, allowAdmiss
|
||||
log.Printf("[worker chat] task %s 恢复意图 #%d 待处理消息失败: %v", t.ID, item.IntentID, recoverErr)
|
||||
}
|
||||
case db.IntentInterventionAccepted:
|
||||
// Reconstruct the Planner-facing event while the Worker hand-off is still
|
||||
// pending. The durable graph_overview view remains authoritative even if
|
||||
// this notification was already consumed before a later restart.
|
||||
t.NotifyWorkerMessage(item.IntentID, item.ActivityID, item.Message)
|
||||
if item.IntentState == "paused" {
|
||||
if changed, setErr := t.Store.CompareAndSetIntentState(item.IntentID, "paused", "open"); setErr != nil {
|
||||
return setErr
|
||||
|
||||
@@ -181,11 +181,6 @@ func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing
|
||||
if accepted.err != nil || accepted.result.ActivityID <= pending.ActivityID || accepted.result.Duplicate || accepted.result.State != "open" {
|
||||
t.Fatalf("accepted Worker message=%+v control=%+v err=%v", accepted.result, pending, accepted.err)
|
||||
}
|
||||
triggers := f.task.drainTriggers()
|
||||
if len(triggers) != 1 || triggers[0].Kind != agent.TriggerWorkerMessage ||
|
||||
triggers[0].ActivityID != accepted.result.ActivityID || triggers[0].IntentID != f.intentID || triggers[0].Detail != message {
|
||||
t.Fatalf("accepted Worker message triggers=%+v, want one matching persistent event", triggers)
|
||||
}
|
||||
node, err := f.task.Store.GetNode(f.intentID)
|
||||
if err != nil || node == nil || node.State != "open" {
|
||||
t.Fatalf("reopened target=%+v err=%v", node, err)
|
||||
@@ -253,39 +248,12 @@ func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing
|
||||
if _, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, "同 request_id 的另一条消息"); !errors.Is(err, errWorkInterventionConflict) {
|
||||
t.Fatalf("same request_id different message error=%v, want conflict", err)
|
||||
}
|
||||
if triggers := f.task.drainTriggers(); len(triggers) != 0 {
|
||||
t.Fatalf("idempotent retries emitted Planner triggers: %+v", triggers)
|
||||
}
|
||||
count, _ = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID)
|
||||
if count != 1 {
|
||||
t.Fatalf("engine retries appended %d Worker messages, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverAcceptedWorkerMessageReconstructsPlannerTrigger(t *testing.T) {
|
||||
f := newInterventionProtocolFixture(t, 1)
|
||||
const requestID = "worker-message-recovery-1"
|
||||
const message = "恢复后优先验证人工指定的权限边界"
|
||||
if _, err := f.task.Store.ReserveIntentIntervention(f.intentID, requestID, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed, err := f.task.Store.CompareAndSetIntentState(f.intentID, "running", "paused"); err != nil || !changed {
|
||||
t.Fatalf("pause before recovery acceptance: changed=%v err=%v", changed, err)
|
||||
}
|
||||
activity, duplicate, err := f.task.Store.AcceptIntentIntervention(f.intentID, requestID, message)
|
||||
if err != nil || duplicate {
|
||||
t.Fatalf("accept before recovery: activity=%+v duplicate=%v err=%v", activity, duplicate, err)
|
||||
}
|
||||
if err := f.engine.RecoverWorkerMessages(context.Background(), f.task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
triggers := f.task.drainTriggers()
|
||||
if len(triggers) != 1 || triggers[0].Kind != agent.TriggerWorkerMessage || triggers[0].ActivityID != activity.ID ||
|
||||
triggers[0].IntentID != f.intentID || triggers[0].Detail != message {
|
||||
t.Fatalf("recovered Planner triggers=%+v", triggers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWorkerMessageContinuesAfterRequestContextCancellation(t *testing.T) {
|
||||
f := newInterventionProtocolFixture(t, 1)
|
||||
workCtx, cancelWork := context.WithCancelCause(context.Background())
|
||||
|
||||
+4
-26
@@ -1639,10 +1639,10 @@ func (t *Task) Notify() {
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWorker wakes one idle execution slot without itself scheduling a Planner
|
||||
// round. Worker-message acceptance separately records a persistent planning event
|
||||
// and calls NotifyWorkerMessage; keeping execution wake-up independent prevents
|
||||
// Planner debounce from delaying the directed Worker claim.
|
||||
// NotifyWorker wakes one idle execution slot without also scheduling a Planner
|
||||
// round. The channel is separate from notify because human intervention needs
|
||||
// the reopened intent reclaimed immediately, while graph-change notifications
|
||||
// are Planner-facing and debounced. A nil channel is tolerated by test fixtures.
|
||||
func (t *Task) NotifyWorker() {
|
||||
if t == nil || t.workerWake == nil {
|
||||
return
|
||||
@@ -1653,28 +1653,6 @@ func (t *Task) NotifyWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWorkerMessage immediately schedules a Planner round for an accepted,
|
||||
// persistent Worker direction. ActivityID identifies the durable kind=user row
|
||||
// and prevents retries in the same debounce window from duplicating the prompt.
|
||||
func (t *Task) NotifyWorkerMessage(intentID, activityID int64, message string) {
|
||||
message = strings.TrimSpace(message)
|
||||
if intentID <= 0 || activityID <= 0 || message == "" {
|
||||
return
|
||||
}
|
||||
t.trigMu.Lock()
|
||||
for _, event := range t.pendingTriggers {
|
||||
if event.Kind == agent.TriggerWorkerMessage && event.ActivityID == activityID {
|
||||
t.trigMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{
|
||||
Kind: agent.TriggerWorkerMessage, ActivityID: activityID, IntentID: intentID, Detail: message,
|
||||
})
|
||||
t.trigMu.Unlock()
|
||||
t.Notify()
|
||||
}
|
||||
|
||||
// NotifyDone is Notify plus a hint: a worker just finished intentID and that is
|
||||
// what triggered this wake-up. The planner reads the accumulated triggers next
|
||||
// round so it can spell out which intent finished (+ its output). Events pile up
|
||||
|
||||
Reference in new issue
Block a user