revert: remove cross-agent worker direction synchronization

This commit is contained in:
pphua committed 2026-08-29 22:05:28 +08:00
1 parent 7e633b2056
commit 76210b9933
9 files changed
+11 -425

No files matched your search

+1 -23
View File
@@ -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
View File
@@ -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 驱动,你无需、也没有别的“一键完成”手段。
-40
View File
@@ -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))
-155
View File
@@ -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)
}
}
-61
View File
@@ -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.
-69
View File
@@ -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)
}
}
-5
View File
@@ -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
View File
@@ -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