mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
新增worker直接对话
This commit is contained in:
1 parent
98d30b28de
commit
cb706d3c8c
17 files changed
+2082
-78
No files matched your search
@@ -45,6 +45,8 @@ var (
|
||||
"规划者调用 kill_work 主动终止了这条意图,通常表示方向跑偏或已无继续价值;意图会标记为 stopped,不会自动重新领取")
|
||||
AbortWorkPausedByUser = cause("work_paused_by_user", "用户暂停了这条 Worker 意图",
|
||||
"用户暂停了正在运行的 Worker。本次调用被取消,意图转为 paused;已经登记的意图、事实、漏洞和活动记录全部保留,恢复后从头重新执行")
|
||||
AbortWorkIntervenedByUser = cause("work_intervened_by_user", "用户暂停 Worker 并发起了新对话",
|
||||
"用户向正在运行的 Worker 发送了新的对话意图。本次调用被立即取消并短暂进入 paused;新消息作为同一会话的下一条用户消息,随后自动恢复并优先重新领取")
|
||||
AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户取消了这条 Worker 意图",
|
||||
"用户取消了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端会事务性删除该意图及其直接产生的事实、漏洞和执行记录")
|
||||
AbortWorkFinished = cause("work_finished", "Worker 已正常结束并释放 context",
|
||||
|
||||
+14
-6
@@ -123,6 +123,7 @@ 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 = 被删目标文本).
|
||||
@@ -130,14 +131,17 @@ 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
|
||||
IntentID int64
|
||||
Detail string
|
||||
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
|
||||
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
|
||||
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
|
||||
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" 专用:修改后的目标文本
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -162,6 +166,9 @@ 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"
|
||||
@@ -250,6 +257,7 @@ 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,6 +48,38 @@ 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 {
|
||||
@@ -360,6 +392,14 @@ 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))
|
||||
|
||||
+54
-2
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
@@ -68,6 +69,30 @@ type Worker struct {
|
||||
nonStreamingFn func() bool
|
||||
}
|
||||
|
||||
// WorkerSessionID returns the stable transcript key used by a worker intent.
|
||||
// Worker slots are reusable, so the intent id (rather than work#N) is the
|
||||
// session identity. Keep this helper public so the Worker message API and UI
|
||||
// can refer to exactly the conversation that will be resumed.
|
||||
func WorkerSessionID(explorationID, intentID int64) string {
|
||||
return fmt.Sprintf("exp%d-worker-i%d", explorationID, intentID)
|
||||
}
|
||||
|
||||
const workerChatMarkerPrefix = "<!-- ARTEX_WORKER_CHAT:"
|
||||
|
||||
func workerChatMarker(requestID string) string {
|
||||
return workerChatMarkerPrefix + requestID + " -->"
|
||||
}
|
||||
|
||||
func hasWorkerChatMessage(messages []llm.Message, requestID string) bool {
|
||||
marker := workerChatMarker(requestID)
|
||||
for _, message := range messages {
|
||||
if message.Role == llm.RoleUser && strings.Contains(message.Text(), marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
|
||||
// model path (true = non-streaming). nil/unset = streaming (default). Read per
|
||||
// run so a profile or task-chain toggle takes effect without rebuilding.
|
||||
@@ -274,6 +299,18 @@ func renderWorkerGraphOverview(data map[string]any) string {
|
||||
// explored but persisted nothing isn't mistaken for done, and the engine can log
|
||||
// facts/assets/findings separately instead of lumping them under "facts").
|
||||
func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string)) (harness.TerminalReason, WriteCounts, error) {
|
||||
return w.execute(ctx, name, taskID, as, ts, intent, hooks, emit, enr, notifyFinding, "", "")
|
||||
}
|
||||
|
||||
// ExecuteWithMessage runs the next turn in the same intent conversation with a
|
||||
// human-authored message. The HTTP handler does not edit the transcript;
|
||||
// agentcore records the message as a normal user turn when this Worker starts.
|
||||
// This keeps Worker continuation identical to the regular agent chat flow.
|
||||
func (w *Worker) ExecuteWithMessage(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string), requestID, message string) (harness.TerminalReason, WriteCounts, error) {
|
||||
return w.execute(ctx, name, taskID, as, ts, intent, hooks, emit, enr, notifyFinding, strings.TrimSpace(requestID), strings.TrimSpace(message))
|
||||
}
|
||||
|
||||
func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string), requestID, message string) (harness.TerminalReason, WriteCounts, error) {
|
||||
tsx := NewToolSet(ts, name)
|
||||
tsx.SetTaskID(taskID)
|
||||
coverageEnabled := as == nil || as.CoverageEnabled(taskID)
|
||||
@@ -358,7 +395,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
}
|
||||
if w.tx != nil { // persist raw LLM conversation; one file per worked intent
|
||||
opts.Transcript = w.tx
|
||||
opts.SessionID = fmt.Sprintf("exp%d-worker-i%d", ts.ID(), intent.ID)
|
||||
opts.SessionID = WorkerSessionID(ts.ID(), intent.ID)
|
||||
}
|
||||
intentID := intent.ID
|
||||
emitWrap := func(r db.Activity) {
|
||||
@@ -398,11 +435,26 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// being re-run. The transcript ID is deterministic per intent, so if a prior
|
||||
// session exists the worker continues from where it left off instead of
|
||||
// restarting from scratch.
|
||||
alreadyRecorded := false
|
||||
if w.tx != nil {
|
||||
_ = s.Resume(opts.SessionID)
|
||||
if len(s.Messages()) > 0 {
|
||||
alreadyRecorded = requestID != "" && hasWorkerChatMessage(s.Messages(), requestID)
|
||||
if len(s.Messages()) > 0 && message == "" {
|
||||
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
||||
input = "继续执行。"
|
||||
} else if len(s.Messages()) > 0 {
|
||||
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
||||
}
|
||||
}
|
||||
if message != "" {
|
||||
if alreadyRecorded {
|
||||
input = "继续执行上一次人工对话输入的新意图。不要重复已经完成的动作。"
|
||||
} else if len(s.Messages()) > 0 {
|
||||
input = workerChatMarker(requestID) + "\n【人工对话输入的新意图】\n" + message +
|
||||
"\n\n请立即按这条人工输入执行,完成后再根据上下文决定原任务是否需要继续。"
|
||||
} else {
|
||||
input += "\n\n" + workerChatMarker(requestID) + "\n【人工对话输入的新意图】\n" + message +
|
||||
"\n\n请优先执行这条人工输入。"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
func TestWorkerSessionIDIsStablePerIntent(t *testing.T) {
|
||||
if got := WorkerSessionID(12, 34); got != "exp12-worker-i34" {
|
||||
t.Fatalf("session id = %q, want exp12-worker-i34", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerChatMarkerOnlyMatchesItsNormalUserTurn(t *testing.T) {
|
||||
requestID := "worker-message-123"
|
||||
messages := []llm.Message{
|
||||
{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock(workerChatMarker(requestID))}},
|
||||
llm.UserText(workerChatMarker("worker-message-other") + "\nother"),
|
||||
llm.UserText(workerChatMarker(requestID) + "\nnew intent"),
|
||||
}
|
||||
if !hasWorkerChatMessage(messages, requestID) {
|
||||
t.Fatal("expected the matching user turn to be detected")
|
||||
}
|
||||
if hasWorkerChatMessage(messages, "worker-message-missing") {
|
||||
t.Fatal("unrelated request id matched a Worker user turn")
|
||||
}
|
||||
}
|
||||
+455
-18
@@ -14,6 +14,11 @@ import (
|
||||
// it to distinguish an expected control race (HTTP 409) from a storage failure.
|
||||
var ErrIntentStateConflict = errors.New("intent state changed concurrently")
|
||||
|
||||
// ErrIntentInterventionConflict marks a rejected or conflicting worker
|
||||
// intervention request. It covers state races, a reused request id with a
|
||||
// different payload, and a second request trying to overtake a pending one.
|
||||
var ErrIntentInterventionConflict = errors.New("intent intervention conflict")
|
||||
|
||||
// utf8Clean makes a string safe for a PostgreSQL text column. It (1) replaces
|
||||
// invalid UTF-8 byte sequences with U+FFFD and (2) strips NUL (0x00) bytes.
|
||||
// Tool output (nmap/curl/… stdout) can carry raw/truncated bytes that PostgreSQL's
|
||||
@@ -330,6 +335,437 @@ WHERE id=$2 AND exploration_id=$3 AND kind='intent' AND state=$4`, state, id, s.
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
const (
|
||||
IntentInterventionPending = "pending"
|
||||
IntentInterventionAccepted = "accepted"
|
||||
IntentInterventionRejected = "rejected"
|
||||
PlannerEventWorkerMessage = "worker_message"
|
||||
)
|
||||
|
||||
// IntentIntervention is the durable control record for one human interruption.
|
||||
// It deliberately lives in activity rather than a new table: activity already
|
||||
// participates in task archive/restore, and the accepted row is also the user
|
||||
// turn rendered in the intent transcript.
|
||||
type IntentIntervention struct {
|
||||
RequestID string
|
||||
Message string
|
||||
Status string
|
||||
ActivityID int64
|
||||
IntentID int64
|
||||
IntentState string
|
||||
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.
|
||||
func lockIntentIntervention(tx *sql.Tx, intentID int64) error {
|
||||
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtextextended('artex-intent-intervention:' || $1::text, 0))`, intentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanIntentIntervention(row *sql.Row) (*IntentIntervention, error) {
|
||||
var it IntentIntervention
|
||||
err := row.Scan(&it.ActivityID, &it.RequestID, &it.Message, &it.Status,
|
||||
&it.IntentID, &it.IntentState, &it.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
// IntentInterventionByRequest finds a prior reservation/acceptance. Callers do
|
||||
// this before ordinary lifecycle validation so retrying an already accepted
|
||||
// request remains idempotent even if the worker has since finished naturally.
|
||||
func (s *ExplorationStore) IntentInterventionByRequest(intentID int64, requestID string) (*IntentIntervention, error) {
|
||||
return scanIntentIntervention(s.db.QueryRow(`
|
||||
SELECT COALESCE(NULLIF(a.metadata->>'accepted_activity_id','')::bigint, a.id),
|
||||
COALESCE(a.metadata->>'intervention_request_id',''),
|
||||
COALESCE(a.metadata->>'intervention_message', a.detail, ''),
|
||||
COALESCE(a.metadata->>'intervention_status',''),
|
||||
n.id, n.state, 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.node_id=$2
|
||||
AND a.metadata->>'intervention_request_id'=$3
|
||||
ORDER BY a.id
|
||||
LIMIT 1`, s.expID, intentID, requestID))
|
||||
}
|
||||
|
||||
// ReserveIntentIntervention durably claims a request id while the intent is
|
||||
// still running. A hidden intervention_control activity is the write-ahead
|
||||
// record; acceptance inserts a newer visible kind='user' row so SSE cursors can
|
||||
// never skip a message whose control reservation had an older id.
|
||||
func (s *ExplorationStore) ReserveIntentIntervention(intentID int64, requestID, message string) (*IntentIntervention, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := lockIntentIntervention(tx, intentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := scanIntentIntervention(tx.QueryRow(`
|
||||
SELECT COALESCE(NULLIF(a.metadata->>'accepted_activity_id','')::bigint, a.id),
|
||||
COALESCE(a.metadata->>'intervention_request_id',''),
|
||||
COALESCE(a.metadata->>'intervention_message', a.detail, ''),
|
||||
COALESCE(a.metadata->>'intervention_status',''),
|
||||
n.id, n.state, 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.node_id=$2
|
||||
AND a.metadata->>'intervention_request_id'=$3
|
||||
ORDER BY a.id
|
||||
LIMIT 1`, s.expID, intentID, requestID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Message != message {
|
||||
return nil, fmt.Errorf("%w: request_id 已用于不同的 Worker 消息", ErrIntentInterventionConflict)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
var state string
|
||||
if err := tx.QueryRow(`SELECT state FROM exploration_nodes
|
||||
WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, intentID, s.expID).Scan(&state); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: intent not found", ErrIntentInterventionConflict)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if state != "running" && state != "paused" {
|
||||
return nil, fmt.Errorf("%w: intent state %s cannot receive a message", ErrIntentInterventionConflict, state)
|
||||
}
|
||||
var otherRequest string
|
||||
err = tx.QueryRow(`SELECT COALESCE(metadata->>'intervention_request_id','')
|
||||
FROM activity
|
||||
WHERE exploration_id=$1 AND node_id=$2
|
||||
AND metadata->>'intervention_status'=$3
|
||||
ORDER BY id LIMIT 1`, s.expID, intentID, IntentInterventionPending).Scan(&otherRequest)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: intent 已有待处理的 Worker 消息 %s", ErrIntentInterventionConflict, otherRequest)
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(map[string]any{
|
||||
"intervention": true,
|
||||
"intervention_handoff": "pending",
|
||||
"intervention_request_id": requestID,
|
||||
"intervention_message": message,
|
||||
"intervention_status": IntentInterventionPending,
|
||||
"source": "user",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out IntentIntervention
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO activity(exploration_id,node_id,worker,kind,metadata)
|
||||
VALUES ($1,$2,'system','intervention_control',$3)
|
||||
RETURNING id, created_at`, s.expID, intentID, string(metadata)).Scan(&out.ActivityID, &out.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.RequestID, out.Message, out.Status = requestID, message, IntentInterventionPending
|
||||
out.IntentID, out.IntentState = intentID, state
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AcceptIntentIntervention is the DB commit point: the hidden reservation
|
||||
// becomes an audited user turn in the same transaction that moves paused back
|
||||
// to open. Thus no Worker can claim the intent before its pending message and
|
||||
// visible activity are both durable.
|
||||
func (s *ExplorationStore) AcceptIntentIntervention(intentID int64, requestID, message string) (Activity, bool, error) {
|
||||
var activity Activity
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := lockIntentIntervention(tx, intentID); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
|
||||
var controlID int64
|
||||
var storedMessage, status, state, taskStatus string
|
||||
var taskPaused, taskQueued bool
|
||||
var taskDeletedAt sql.NullTime
|
||||
var createdAt time.Time
|
||||
err = tx.QueryRow(`
|
||||
SELECT a.id,
|
||||
COALESCE(a.metadata->>'intervention_message', a.detail, ''),
|
||||
COALESCE(a.metadata->>'intervention_status',''),
|
||||
n.state, a.created_at, COALESCE(t.status,''), t.paused, t.queued, t.deleted_at
|
||||
FROM activity a
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
JOIN tasks t ON t.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1 AND a.node_id=$2
|
||||
AND a.metadata->>'intervention_request_id'=$3
|
||||
ORDER BY a.id LIMIT 1
|
||||
FOR UPDATE OF a,n,t`, s.expID, intentID, requestID).
|
||||
Scan(&controlID, &storedMessage, &status, &state, &createdAt,
|
||||
&taskStatus, &taskPaused, &taskQueued, &taskDeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return activity, false, fmt.Errorf("%w: intervention reservation not found", ErrIntentInterventionConflict)
|
||||
}
|
||||
if err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
if storedMessage != message {
|
||||
return activity, false, fmt.Errorf("%w: request_id 已用于不同的 Worker 消息", ErrIntentInterventionConflict)
|
||||
}
|
||||
if status == IntentInterventionAccepted {
|
||||
var acceptedID int64
|
||||
if err := tx.QueryRow(`SELECT COALESCE((metadata->>'accepted_activity_id')::bigint,0),
|
||||
COALESCE((SELECT created_at FROM activity accepted
|
||||
WHERE accepted.id=(activity.metadata->>'accepted_activity_id')::bigint), created_at)
|
||||
FROM activity WHERE id=$1`, controlID).Scan(&acceptedID, &createdAt); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
if acceptedID <= 0 {
|
||||
return activity, false, fmt.Errorf("accepted intervention has no activity id")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
nid := intentID
|
||||
activity.ID = acceptedID
|
||||
activity.NodeID, activity.Worker, activity.Kind = &nid, "user", "user"
|
||||
activity.Summary, activity.Detail, activity.CreatedAt = message, message, createdAt
|
||||
return activity, true, nil
|
||||
}
|
||||
if status != IntentInterventionPending {
|
||||
return activity, false, fmt.Errorf("%w: intervention status %s cannot be accepted", ErrIntentInterventionConflict, status)
|
||||
}
|
||||
if state != "paused" {
|
||||
return activity, false, fmt.Errorf("%w: intent state %s cannot be reopened", ErrIntentInterventionConflict, state)
|
||||
}
|
||||
if taskPaused || taskQueued || taskDeletedAt.Valid || IsTerminal(taskStatus) {
|
||||
return activity, false, fmt.Errorf("%w: task lifecycle changed (status=%s paused=%v queued=%v deleted=%v)",
|
||||
ErrIntentInterventionConflict, taskStatus, taskPaused, taskQueued, taskDeletedAt.Valid)
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(map[string]any{
|
||||
"intervention": true,
|
||||
"intervention_request_id": requestID,
|
||||
"intervention_message": message,
|
||||
"intervention_status": IntentInterventionAccepted,
|
||||
"planner_event": PlannerEventWorkerMessage,
|
||||
"source": "user",
|
||||
})
|
||||
if err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
if err := tx.QueryRow(`INSERT INTO activity(
|
||||
exploration_id,node_id,worker,kind,summary,detail,metadata)
|
||||
VALUES ($1,$2,'user','user',$3,$3,$4)
|
||||
RETURNING id,created_at`, s.expID, intentID, utf8Clean(message), string(metadata)).
|
||||
Scan(&activity.ID, &createdAt); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
var acceptedMeta map[string]any
|
||||
if err := json.Unmarshal(metadata, &acceptedMeta); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
acceptedMeta["accepted_activity_id"] = activity.ID
|
||||
controlMetadata, err := json.Marshal(acceptedMeta)
|
||||
if err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE activity SET metadata=$1 WHERE id=$2`, string(controlMetadata), controlID); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE exploration_nodes
|
||||
SET state='open', owner=NULL, completed_at=NULL, blocked_reason=NULL
|
||||
WHERE id=$1 AND exploration_id=$2 AND kind='intent' AND state='paused'`, intentID, s.expID)
|
||||
if err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
if err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
return activity, false, fmt.Errorf("%w: intent is no longer paused", ErrIntentInterventionConflict)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return activity, false, err
|
||||
}
|
||||
nid := intentID
|
||||
activity.NodeID, activity.Worker, activity.Kind = &nid, "user", "user"
|
||||
activity.Summary, activity.Detail, activity.CreatedAt = message, message, createdAt
|
||||
activity.Metadata = metadata
|
||||
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.
|
||||
func (s *ExplorationStore) RecoverableIntentInterventions() ([]IntentIntervention, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT COALESCE(NULLIF(a.metadata->>'accepted_activity_id','')::bigint, a.id),
|
||||
COALESCE(a.metadata->>'intervention_request_id',''),
|
||||
COALESCE(a.metadata->>'intervention_message',''),
|
||||
COALESCE(a.metadata->>'intervention_status',''),
|
||||
n.id,n.state,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='intervention_control'
|
||||
AND (a.metadata->>'intervention_status'=$2 OR (
|
||||
a.metadata->>'intervention_status'=$3
|
||||
AND COALESCE(a.metadata->>'intervention_handoff','pending')='pending'))
|
||||
ORDER BY a.id`, s.expID, IntentInterventionPending, IntentInterventionAccepted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []IntentIntervention{}
|
||||
for rows.Next() {
|
||||
var item IntentIntervention
|
||||
if err := rows.Scan(&item.ActivityID, &item.RequestID, &item.Message, &item.Status,
|
||||
&item.IntentID, &item.IntentState, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkIntentInterventionHandoffClaimed records that the accepted instruction
|
||||
// has been preferentially claimed once. Idempotency status remains accepted.
|
||||
func (s *ExplorationStore) MarkIntentInterventionHandoffClaimed(intentID int64) error {
|
||||
_, err := s.db.Exec(`UPDATE activity SET metadata=jsonb_set(
|
||||
metadata,'{intervention_handoff}',to_jsonb('claimed'::text))
|
||||
WHERE id=(SELECT id FROM activity
|
||||
WHERE exploration_id=$1 AND node_id=$2 AND kind='intervention_control'
|
||||
AND metadata->>'intervention_status'=$3
|
||||
AND COALESCE(metadata->>'intervention_handoff','pending')='pending'
|
||||
ORDER BY id DESC LIMIT 1)`, s.expID, intentID, IntentInterventionAccepted)
|
||||
return err
|
||||
}
|
||||
|
||||
// PendingIntentInterventionMessage returns the accepted human chat turn that
|
||||
// should be consumed by the next Worker run. The handoff remains pending until
|
||||
// the run has actually started, so a process restart can reconstruct the
|
||||
// directed claim without losing the user's message.
|
||||
func (s *ExplorationStore) PendingIntentInterventionMessage(intentID int64) (string, string, bool, error) {
|
||||
var requestID, message string
|
||||
err := s.db.QueryRow(`SELECT
|
||||
COALESCE(metadata->>'intervention_request_id',''),
|
||||
COALESCE(metadata->>'intervention_message','')
|
||||
FROM activity
|
||||
WHERE exploration_id=$1 AND node_id=$2 AND kind='intervention_control'
|
||||
AND metadata->>'intervention_status'=$3
|
||||
AND COALESCE(metadata->>'intervention_handoff','pending')='pending'
|
||||
ORDER BY id DESC LIMIT 1`, s.expID, intentID, IntentInterventionAccepted).
|
||||
Scan(&requestID, &message)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return requestID, message, true, nil
|
||||
}
|
||||
|
||||
// RejectIntentIntervention closes a pending control reservation without making
|
||||
// it visible in task activity. It is used when lifecycle validation changes or
|
||||
// the transcript could not be made durable; a different future request is then
|
||||
// free to proceed after the user explicitly resumes the paused intent.
|
||||
func (s *ExplorationStore) RejectIntentIntervention(intentID int64, requestID, reason string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := lockIntentIntervention(tx, intentID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE activity
|
||||
SET metadata=jsonb_set(jsonb_set(metadata,'{intervention_status}',to_jsonb($1::text)),
|
||||
'{intervention_reject_reason}',to_jsonb($2::text))
|
||||
WHERE exploration_id=$3 AND node_id=$4
|
||||
AND metadata->>'intervention_request_id'=$5
|
||||
AND metadata->>'intervention_status'=$6`,
|
||||
IntentInterventionRejected, utf8Clean(reason), s.expID, intentID, requestID, IntentInterventionPending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// IntentCleanup summarizes the blackboard records removed when a user cancels
|
||||
// one worker intent. Global assets and traffic are deliberately not part of this
|
||||
// operation; exploration anchors disappear only because their owning nodes do.
|
||||
@@ -1006,7 +1442,8 @@ func (d *DB) TokenTotalsAll() (map[int64]TokenUsage, error) {
|
||||
// Persisted (unlike Engine.LastActivity's in-memory map), so it survives restarts
|
||||
// and gives终态任务 a stable "ran until" time for computing run duration.
|
||||
func (d *DB) LastActivityAll() (map[int64]int64, error) {
|
||||
rows, err := d.Query(`SELECT exploration_id, EXTRACT(EPOCH FROM MAX(created_at))::bigint FROM activity GROUP BY exploration_id`)
|
||||
rows, err := d.Query(`SELECT exploration_id, EXTRACT(EPOCH FROM MAX(created_at))::bigint
|
||||
FROM activity WHERE kind IS DISTINCT FROM 'intervention_control' GROUP BY exploration_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1057,7 +1494,7 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT EXTRACT(EPOCH FROM created_at)::bigint AS created_at
|
||||
FROM activity
|
||||
WHERE exploration_id=task.exploration_id
|
||||
WHERE exploration_id=task.exploration_id AND kind IS DISTINCT FROM 'intervention_control'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) latest_activity ON true
|
||||
@@ -1188,10 +1625,10 @@ func (s *ExplorationStore) ActivityList(nodeID *int64, sinceID int64, limit int)
|
||||
const cols = `id, node_id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), metadata, created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
|
||||
if nodeID != nil {
|
||||
rows, err = s.db.Query(`SELECT `+cols+`
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND id>$3 ORDER BY id LIMIT $4`, s.expID, *nodeID, sinceID, limit)
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind IS DISTINCT FROM 'intervention_control' AND id>$3 ORDER BY id LIMIT $4`, s.expID, *nodeID, sinceID, limit)
|
||||
} else {
|
||||
rows, err = s.db.Query(`SELECT `+cols+`
|
||||
FROM activity WHERE exploration_id=$1 AND id>$2 ORDER BY id LIMIT $3`, s.expID, sinceID, limit)
|
||||
FROM activity WHERE exploration_id=$1 AND kind IS DISTINCT FROM 'intervention_control' AND id>$2 ORDER BY id LIMIT $3`, s.expID, sinceID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, sinceID, err
|
||||
@@ -1228,7 +1665,7 @@ func (s *ExplorationStore) ActivityListForTerminalIntent(nodeID, sinceID int64,
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1 AND a.node_id=$2 AND a.id>$3
|
||||
AND n.kind='intent' AND n.state IN ('done','blocked','exhausted','stopped')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND a.kind NOT IN ('thinking','usage','intervention_control')
|
||||
ORDER BY a.id LIMIT $4`, s.expID, nodeID, sinceID, limit)
|
||||
if err != nil {
|
||||
return nil, sinceID, err
|
||||
@@ -1291,7 +1728,7 @@ func (s *ExplorationStore) ActivityPage(f ActivitySessionFilter, before int64, l
|
||||
limitArg := len(args) + 1
|
||||
args = append(args, limit+1) // one extra row to detect older history
|
||||
q := fmt.Sprintf(`SELECT `+cols+`
|
||||
FROM activity WHERE exploration_id=$1%s AND ($%d <= 0 OR id < $%d)
|
||||
FROM activity WHERE exploration_id=$1 AND kind IS DISTINCT FROM 'intervention_control'%s AND ($%d <= 0 OR id < $%d)
|
||||
ORDER BY id DESC LIMIT $%d`, cond, beforeArg, beforeArg, limitArg)
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
@@ -1336,7 +1773,7 @@ func (s *ExplorationStore) ActivityPageForTerminalIntent(nodeID, before int64, l
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1 AND a.node_id=$2
|
||||
AND n.kind='intent' AND n.state IN ('done','blocked','exhausted','stopped')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND a.kind NOT IN ('thinking','usage','intervention_control')
|
||||
AND ($3 <= 0 OR a.id < $3)
|
||||
ORDER BY a.id DESC LIMIT $4`, s.expID, nodeID, before, limit+1)
|
||||
if err != nil {
|
||||
@@ -1372,7 +1809,7 @@ func (s *ExplorationStore) ActivityPageForTerminalIntent(nodeID, before int64, l
|
||||
// session, since one SSE covers the whole task.
|
||||
func (s *ExplorationStore) ActivityMaxID() (int64, error) {
|
||||
var max sql.NullInt64
|
||||
err := s.db.QueryRow(`SELECT MAX(id) FROM activity WHERE exploration_id=$1`, s.expID).Scan(&max)
|
||||
err := s.db.QueryRow(`SELECT MAX(id) FROM activity WHERE exploration_id=$1 AND kind IS DISTINCT FROM 'intervention_control'`, s.expID).Scan(&max)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1382,7 +1819,7 @@ func (s *ExplorationStore) ActivityMaxID() (int64, error) {
|
||||
// ActivityDetail lazily returns the full detail blob for one step.
|
||||
func (s *ExplorationStore) ActivityDetail(id int64) (string, error) {
|
||||
var d sql.NullString
|
||||
err := s.db.QueryRow(`SELECT detail FROM activity WHERE id=$1 AND exploration_id=$2`, id, s.expID).Scan(&d)
|
||||
err := s.db.QueryRow(`SELECT detail FROM activity WHERE id=$1 AND exploration_id=$2 AND kind IS DISTINCT FROM 'intervention_control'`, id, s.expID).Scan(&d)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
@@ -1415,7 +1852,7 @@ func (s *ExplorationStore) ActivityTrace(nodeID int64, limit int) ([]Activity, e
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT `+traceCols+`
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind NOT IN ('thinking','usage')
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind NOT IN ('thinking','usage','intervention_control')
|
||||
ORDER BY id LIMIT $3`, s.expID, nodeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1435,7 +1872,7 @@ func (s *ExplorationStore) ActivityTraceForTerminalIntent(nodeID int64, limit in
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1 AND a.node_id=$2 AND n.kind='intent'
|
||||
AND n.state IN ('done','blocked','exhausted','stopped')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND a.kind NOT IN ('thinking','usage','intervention_control')
|
||||
ORDER BY a.id LIMIT $3`, s.expID, nodeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1457,11 +1894,11 @@ func (s *ExplorationStore) ActivityTraceSearch(nodeID *int64, q string, limit in
|
||||
var err error
|
||||
if nodeID != nil {
|
||||
rows, err = s.db.Query(`SELECT `+traceCols+`
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind NOT IN ('thinking','usage')
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind NOT IN ('thinking','usage','intervention_control')
|
||||
AND (summary ILIKE $3 OR detail ILIKE $3) ORDER BY id LIMIT $4`, s.expID, *nodeID, like, limit)
|
||||
} else {
|
||||
rows, err = s.db.Query(`SELECT `+traceCols+`
|
||||
FROM activity WHERE exploration_id=$1 AND node_id IS NOT NULL AND kind NOT IN ('thinking','usage')
|
||||
FROM activity WHERE exploration_id=$1 AND node_id IS NOT NULL AND kind NOT IN ('thinking','usage','intervention_control')
|
||||
AND (summary ILIKE $2 OR detail ILIKE $2) ORDER BY id LIMIT $3`, s.expID, like, limit)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1483,7 +1920,7 @@ func (s *ExplorationStore) ActivityTraceSearchForTerminalIntent(nodeID int64, q
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1 AND a.node_id=$2 AND n.kind='intent'
|
||||
AND n.state IN ('done','blocked','exhausted','stopped')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND a.kind NOT IN ('thinking','usage','intervention_control')
|
||||
AND (a.summary ILIKE $3 OR a.detail ILIKE $3)
|
||||
ORDER BY a.id LIMIT $4`, s.expID, nodeID, like, limit)
|
||||
if err != nil {
|
||||
@@ -1505,7 +1942,7 @@ func (s *ExplorationStore) ActivityTraceSearchTerminalIntents(q string, limit in
|
||||
JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
|
||||
WHERE a.exploration_id=$1 AND n.kind='intent'
|
||||
AND n.state IN ('done','blocked','exhausted','stopped')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND a.kind NOT IN ('thinking','usage','intervention_control')
|
||||
AND (a.summary ILIKE $2 OR a.detail ILIKE $2)
|
||||
ORDER BY a.id LIMIT $3`, s.expID, like, limit)
|
||||
if err != nil {
|
||||
@@ -1576,7 +2013,7 @@ func (s *ExplorationStore) ActivityTraceSearchExcluding(excludeNodeID int64, q s
|
||||
like := "%" + q + "%"
|
||||
rows, err := s.db.Query(`SELECT `+traceCols+`
|
||||
FROM activity WHERE exploration_id=$1 AND node_id IS NOT NULL AND node_id <> $2
|
||||
AND kind NOT IN ('thinking','usage')
|
||||
AND kind NOT IN ('thinking','usage','intervention_control')
|
||||
AND (summary ILIKE $3 OR detail ILIKE $3) ORDER BY id LIMIT $4`, s.expID, excludeNodeID, like, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1600,7 +2037,7 @@ func (s *ExplorationStore) ActivityByIDs(ids []int64) ([]Activity, error) {
|
||||
args[i+1] = id
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT id, node_id, COALESCE(kind,''), COALESCE(tool,''), is_error, COALESCE(detail,'')
|
||||
FROM activity WHERE exploration_id=$1 AND kind<>'thinking' AND id IN (`+strings.Join(ph, ",")+`) ORDER BY id`, args...)
|
||||
FROM activity WHERE exploration_id=$1 AND kind NOT IN ('thinking','intervention_control') AND id IN (`+strings.Join(ph, ",")+`) ORDER BY id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1634,7 +2071,7 @@ func (s *ExplorationStore) ActivityByIDsForTerminalIntents(ids []int64) ([]Activ
|
||||
rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.kind,''), COALESCE(a.tool,''), a.is_error, COALESCE(a.detail,'')
|
||||
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 NOT IN ('thinking','usage') AND n.kind='intent'
|
||||
WHERE a.exploration_id=$1 AND a.kind NOT IN ('thinking','usage','intervention_control') AND n.kind='intent'
|
||||
AND n.state IN ('done','blocked','exhausted','stopped')
|
||||
AND a.id IN (`+strings.Join(ph, ",")+`) ORDER BY a.id`, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newRunningInterventionIntent(t *testing.T, d *DB, label string) (*ExplorationStore, int64, func()) {
|
||||
t.Helper()
|
||||
task, err := d.CreateTask("worker intervention "+label, "verify intervention protocol", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := d.Exploration(task.ExplorationID)
|
||||
intentID, err := store.AddIntent(map[string]any{"summary": label}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claimed, err := store.ClaimIntent(intentID, "work#1"); err != nil || !claimed {
|
||||
_ = d.DeleteTask(task.ID)
|
||||
t.Fatalf("claim intervention intent: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
return store, intentID, func() { _ = d.DeleteTask(task.ID) }
|
||||
}
|
||||
|
||||
func TestIntentInterventionReservationAndAcceptanceAreIdempotent(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
store, intentID, cleanup := newRunningInterventionIntent(t, d, "idempotent")
|
||||
defer cleanup()
|
||||
|
||||
const requestID = "intervention-idempotent-1"
|
||||
const message = "停止枚举,优先验证登录越权"
|
||||
reserved, err := store.ReserveIntentIntervention(intentID, requestID, message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reserved.Status != IntentInterventionPending || reserved.ActivityID <= 0 || reserved.IntentState != "running" {
|
||||
t.Fatalf("unexpected reservation: %+v", reserved)
|
||||
}
|
||||
|
||||
replayed, err := store.ReserveIntentIntervention(intentID, requestID, message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replayed.ActivityID != reserved.ActivityID || replayed.Status != IntentInterventionPending {
|
||||
t.Fatalf("reservation retry created a different operation: first=%+v retry=%+v", reserved, replayed)
|
||||
}
|
||||
if _, err := store.ReserveIntentIntervention(intentID, requestID, "改成另一条 Worker 消息"); !errors.Is(err, ErrIntentInterventionConflict) {
|
||||
t.Fatalf("same request_id with different message error=%v, want intervention conflict", err)
|
||||
}
|
||||
if _, err := store.ReserveIntentIntervention(intentID, "intervention-other", message); !errors.Is(err, ErrIntentInterventionConflict) {
|
||||
t.Fatalf("second pending request error=%v, want intervention conflict", err)
|
||||
}
|
||||
|
||||
if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed {
|
||||
t.Fatalf("settle intent before acceptance: changed=%v err=%v", changed, err)
|
||||
}
|
||||
activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, message)
|
||||
if err != nil || duplicate {
|
||||
t.Fatalf("accept intervention: duplicate=%v err=%v", duplicate, err)
|
||||
}
|
||||
if activity.ID <= reserved.ActivityID || activity.NodeID == nil || *activity.NodeID != intentID ||
|
||||
activity.Worker != "user" || activity.Kind != "user" || activity.Summary != message || activity.Detail != message {
|
||||
t.Fatalf("accepted audit mismatch: %+v", activity)
|
||||
}
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal(activity.Metadata, &metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata["intervention_request_id"] != requestID || metadata["intervention_status"] != IntentInterventionAccepted {
|
||||
t.Fatalf("accepted metadata=%v", metadata)
|
||||
}
|
||||
node, err := store.GetNode(intentID)
|
||||
if err != nil || node == nil || node.State != "open" || node.Owner != "" {
|
||||
t.Fatalf("accepted intent=%+v err=%v, want reopened and unowned", node, err)
|
||||
}
|
||||
|
||||
again, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, message)
|
||||
if err != nil || !duplicate || again.ID != activity.ID {
|
||||
t.Fatalf("accept retry: activity=%+v duplicate=%v err=%v", again, duplicate, err)
|
||||
}
|
||||
lookup, err := store.IntentInterventionByRequest(intentID, requestID)
|
||||
if err != nil || lookup == nil || lookup.Status != IntentInterventionAccepted || lookup.ActivityID != activity.ID {
|
||||
t.Fatalf("accepted lookup=%+v err=%v", lookup, err)
|
||||
}
|
||||
var rows int
|
||||
if err := d.QueryRow(`SELECT COUNT(*) FROM activity
|
||||
WHERE exploration_id=$1 AND node_id=$2
|
||||
AND metadata->>'intervention_request_id'=$3`, store.ID(), intentID, requestID).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("Worker message rows=%d, want one hidden control row and one visible user row", rows)
|
||||
}
|
||||
activities, cursor, err := store.ActivityList(&intentID, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(activities) != 1 || activities[0].ID != activity.ID || cursor != activity.ID {
|
||||
t.Fatalf("public activities leaked control row: activities=%+v cursor=%d", activities, cursor)
|
||||
}
|
||||
maxID, err := store.ActivityMaxID()
|
||||
if err != nil || maxID != activity.ID {
|
||||
t.Fatalf("public activity cursor=%d err=%v, want visible user row %d", maxID, err, activity.ID)
|
||||
}
|
||||
if detail, err := store.ActivityDetail(reserved.ActivityID); err != nil || detail != "" {
|
||||
t.Fatalf("hidden control detail=%q err=%v", detail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentInterventionConcurrentReservationHasSingleDurableWinner(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
store, intentID, cleanup := newRunningInterventionIntent(t, d, "concurrent")
|
||||
defer cleanup()
|
||||
|
||||
const contenders = 12
|
||||
const requestID = "intervention-concurrent-1"
|
||||
const message = "只保留被动请求并验证当前假设"
|
||||
start := make(chan struct{})
|
||||
results := make(chan *IntentIntervention, contenders)
|
||||
errs := make(chan error, contenders)
|
||||
var wg sync.WaitGroup
|
||||
for range contenders {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
reservation, reserveErr := store.ReserveIntentIntervention(intentID, requestID, message)
|
||||
results <- reservation
|
||||
errs <- reserveErr
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent reservation: %v", err)
|
||||
}
|
||||
}
|
||||
var activityID int64
|
||||
for result := range results {
|
||||
if result == nil || result.ActivityID <= 0 {
|
||||
t.Fatalf("invalid concurrent reservation: %+v", result)
|
||||
}
|
||||
if activityID == 0 {
|
||||
activityID = result.ActivityID
|
||||
} else if result.ActivityID != activityID {
|
||||
t.Fatalf("concurrent requests produced activity ids %d and %d", activityID, result.ActivityID)
|
||||
}
|
||||
}
|
||||
var rows int
|
||||
if err := d.QueryRow(`SELECT COUNT(*) FROM activity
|
||||
WHERE exploration_id=$1 AND node_id=$2
|
||||
AND metadata->>'intervention_request_id'=$3`, store.ID(), intentID, requestID).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("concurrent intervention rows=%d, want 1", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentInterventionAcceptanceCanRetryAfterStateSettlement(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
store, intentID, cleanup := newRunningInterventionIntent(t, d, "settlement retry")
|
||||
defer cleanup()
|
||||
|
||||
const requestID = "intervention-settle-1"
|
||||
const message = "立刻停止当前动作并检查权限边界"
|
||||
reserved, err := store.ReserveIntentIntervention(intentID, requestID, message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := store.AcceptIntentIntervention(intentID, requestID, message); !errors.Is(err, ErrIntentInterventionConflict) {
|
||||
t.Fatalf("accept before worker settlement error=%v, want conflict", err)
|
||||
}
|
||||
pending, err := store.IntentInterventionByRequest(intentID, requestID)
|
||||
if err != nil || pending == nil || pending.Status != IntentInterventionPending || pending.ActivityID != reserved.ActivityID {
|
||||
t.Fatalf("failed acceptance lost reservation: pending=%+v err=%v", pending, err)
|
||||
}
|
||||
if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed {
|
||||
t.Fatalf("settle intent: changed=%v err=%v", changed, err)
|
||||
}
|
||||
activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, message)
|
||||
if err != nil || duplicate || activity.ID <= reserved.ActivityID {
|
||||
t.Fatalf("retry acceptance: activity=%+v duplicate=%v err=%v", activity, duplicate, err)
|
||||
}
|
||||
}
|
||||
+489
-12
@@ -75,6 +75,7 @@ const (
|
||||
)
|
||||
|
||||
var errWorkControlConflict = errors.New("work control conflict")
|
||||
var errWorkInterventionConflict = errors.New("work intervention conflict")
|
||||
|
||||
// retryableWorkerModelError excludes errors already handled by the task router.
|
||||
// In particular, a quota error after partial streaming advances the task cursor
|
||||
@@ -130,6 +131,17 @@ type Engine struct {
|
||||
// runWorkerStep has stopped writing and committed its final state.
|
||||
workMu sync.Mutex
|
||||
work map[int64]*workExecution
|
||||
// interventionLocks serializes reservation, cancellation and message hand-off
|
||||
// per intent. PostgreSQL separately serializes the durable transaction steps.
|
||||
interventionLocks sync.Map // intentID -> *sync.Mutex
|
||||
|
||||
// directedClaims keeps intents with a new human message ahead of the normal
|
||||
// frontier without mutating their persisted priority. runWorkerStep installs
|
||||
// the entry before releasing ControlWork, so a single worker slot cannot grab
|
||||
// unrelated work in the paused->open hand-off gap.
|
||||
directedMu sync.Mutex
|
||||
directedClaims map[string][]int64
|
||||
directedReady map[int64]bool
|
||||
|
||||
// steerBox queues planner course-corrections for a running work (keyed by intent
|
||||
// id). The worker's PreToolUse hook drains it before its next tool call and hands
|
||||
@@ -162,7 +174,14 @@ type taskRuntime struct {
|
||||
type workExecution struct {
|
||||
cancel context.CancelCauseFunc
|
||||
done chan error
|
||||
action string // user action: pause | cancel
|
||||
action string // user action: pause | cancel | intervene
|
||||
}
|
||||
|
||||
type WorkInterventionResult struct {
|
||||
RequestID string
|
||||
ActivityID int64
|
||||
State string
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// nextPlannerRound returns the next planner round number for a task (1-based).
|
||||
@@ -285,6 +304,12 @@ func (e *Engine) StopTask(taskID string) {
|
||||
e.stamped.Delete(taskID)
|
||||
e.inflight.Delete(taskID)
|
||||
e.coordStarted.Delete(taskID)
|
||||
e.directedMu.Lock()
|
||||
for _, intentID := range e.directedClaims[taskID] {
|
||||
delete(e.directedReady, intentID)
|
||||
}
|
||||
delete(e.directedClaims, taskID)
|
||||
e.directedMu.Unlock()
|
||||
e.deleteMu.Lock()
|
||||
e.deleting.Delete(taskID)
|
||||
e.deleteMu.Unlock()
|
||||
@@ -390,7 +415,7 @@ func NewEngine(m *Manager) *Engine {
|
||||
return &Engine{m: m, debounce: 800 * time.Millisecond, bc: NewBroadcaster(),
|
||||
execCancel: map[string]context.CancelCauseFunc{}, execCtx: map[string]context.Context{},
|
||||
work: map[int64]*workExecution{}, steerBox: map[int64][]string{},
|
||||
runtimes: map[string]*taskRuntime{}}
|
||||
directedClaims: map[string][]int64{}, directedReady: map[int64]bool{}, runtimes: map[string]*taskRuntime{}}
|
||||
}
|
||||
|
||||
// registerWork records the cancel for the work currently running intentID.
|
||||
@@ -425,7 +450,7 @@ func (e *Engine) detachWork(intentID int64) (action string, complete func(error)
|
||||
// worker has fully stopped writing. Cancellation cleanup is performed by the API
|
||||
// handler after this returns; pause state is committed by runWorkerStep itself.
|
||||
func (e *Engine) ControlWork(ctx context.Context, intentID int64, action string) error {
|
||||
if action != "pause" && action != "cancel" {
|
||||
if action != "pause" && action != "cancel" && action != "intervene" {
|
||||
return fmt.Errorf("unsupported work action %q", action)
|
||||
}
|
||||
if ctx == nil {
|
||||
@@ -444,8 +469,11 @@ func (e *Engine) ControlWork(ctx context.Context, intentID int64, action string)
|
||||
run.action = action
|
||||
done := run.done
|
||||
cause := error(agent.AbortWorkPausedByUser)
|
||||
if action == "cancel" {
|
||||
switch action {
|
||||
case "cancel":
|
||||
cause = agent.AbortWorkCancelledByUser
|
||||
case "intervene":
|
||||
cause = agent.AbortWorkIntervenedByUser
|
||||
}
|
||||
run.cancel(cause)
|
||||
e.workMu.Unlock()
|
||||
@@ -464,6 +492,287 @@ func (e *Engine) ControlWork(ctx context.Context, intentID int64, action string)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) lockWorkIntervention(intentID int64) func() {
|
||||
value, _ := e.interventionLocks.LoadOrStore(intentID, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
func (e *Engine) validateWorkInterventionTask(t *Task, allowAdmissionPause bool) error {
|
||||
if t == nil {
|
||||
return fmt.Errorf("%w: task not found", errWorkInterventionConflict)
|
||||
}
|
||||
lifecycle := t.lifecycleSnapshot()
|
||||
switch {
|
||||
case e.IsDeleting(t.ID):
|
||||
return fmt.Errorf("%w: 任务正在删除,无法向 Worker 发送消息", errWorkInterventionConflict)
|
||||
case lifecycle.Paused || (!allowAdmissionPause && e.IsPaused(t.ID)):
|
||||
return fmt.Errorf("%w: 任务已暂停,无法向 Worker 发送消息", errWorkInterventionConflict)
|
||||
case lifecycle.Queued:
|
||||
return fmt.Errorf("%w: 排队中的任务无法向 Worker 发送消息", errWorkInterventionConflict)
|
||||
case isTerminalStatus(lifecycle.Status):
|
||||
return fmt.Errorf("%w: 终态任务无法向 Worker 发送消息", errWorkInterventionConflict)
|
||||
case e.isSettling(t.ID):
|
||||
return fmt.Errorf("%w: 任务正在收尾,无法向 Worker 发送消息", errWorkInterventionConflict)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) abandonWorkIntervention(t *Task, intentID int64, requestID string, reason error) {
|
||||
e.removeDirectedClaim(t.ID, intentID)
|
||||
target := "open"
|
||||
lifecycle := t.lifecycleSnapshot()
|
||||
if e.IsDeleting(t.ID) || isTerminalStatus(lifecycle.Status) {
|
||||
target = "stopped"
|
||||
} else if e.isSettling(t.ID) {
|
||||
target = "exhausted"
|
||||
}
|
||||
if changed, err := t.Store.CompareAndSetIntentState(intentID, "paused", target); err != nil {
|
||||
log.Printf("[worker intervention] task %s 意图 #%d 补偿 paused->%s 失败: %v", t.ID, intentID, target, err)
|
||||
} else if changed && target == "open" {
|
||||
t.NotifyWorker()
|
||||
}
|
||||
if err := t.Store.RejectIntentIntervention(intentID, requestID, reason.Error()); err != nil {
|
||||
log.Printf("[worker intervention] task %s 意图 #%d 标记请求 %s rejected 失败: %v", t.ID, intentID, requestID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InterveneWork performs the complete Worker message protocol. Once the request
|
||||
// has a durable pending reservation it no longer depends on the HTTP request
|
||||
// context: the caller supplies a server-owned bounded context so disconnects do
|
||||
// not strand the intent. The accepted DB transition is running -> paused ->
|
||||
// open, with the visible user message and reopen committed atomically.
|
||||
func (e *Engine) InterveneWork(ctx context.Context, t *Task, intentID int64, requestID, message string) (WorkInterventionResult, error) {
|
||||
return e.interveneWork(ctx, t, intentID, requestID, message, false)
|
||||
}
|
||||
|
||||
func (e *Engine) interveneWork(ctx context.Context, t *Task, intentID int64, requestID, message string, allowAdmissionPause bool) (WorkInterventionResult, error) {
|
||||
out := WorkInterventionResult{RequestID: requestID}
|
||||
if t == nil || t.Store == nil {
|
||||
return out, fmt.Errorf("%w: task not found", errWorkInterventionConflict)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
unlock := e.lockWorkIntervention(intentID)
|
||||
defer unlock()
|
||||
|
||||
existing, err := t.Store.IntentInterventionByRequest(intentID, requestID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Message != message {
|
||||
return out, fmt.Errorf("%w: request_id 已用于不同的 Worker 消息", errWorkInterventionConflict)
|
||||
}
|
||||
if existing.Status == db.IntentInterventionAccepted {
|
||||
out.ActivityID, out.State, out.Duplicate = existing.ActivityID, existing.IntentState, true
|
||||
if out.State == "open" && e.validateWorkInterventionTask(t, allowAdmissionPause) == nil {
|
||||
e.queueDirectedClaim(t.ID, intentID)
|
||||
e.readyDirectedClaim(intentID)
|
||||
t.NotifyWorker()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if existing.Status != db.IntentInterventionPending {
|
||||
return out, fmt.Errorf("%w: Worker 消息请求状态为 %s", errWorkInterventionConflict, existing.Status)
|
||||
}
|
||||
}
|
||||
if err := e.validateWorkInterventionTask(t, allowAdmissionPause); err != nil {
|
||||
lifecycle := t.lifecycleSnapshot()
|
||||
if existing != nil && (e.IsDeleting(t.ID) || e.isSettling(t.ID) || isTerminalStatus(lifecycle.Status)) {
|
||||
e.abandonWorkIntervention(t, intentID, requestID, err)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
_, worker := e.snapshotFor(t)
|
||||
if worker == nil {
|
||||
return out, fmt.Errorf("%w: worker 尚未就绪", errWorkInterventionConflict)
|
||||
}
|
||||
if existing == nil {
|
||||
existing, err = t.Store.ReserveIntentIntervention(intentID, requestID, message)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrIntentInterventionConflict) {
|
||||
return out, fmt.Errorf("%w: %v", errWorkInterventionConflict, err)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
out.ActivityID = existing.ActivityID
|
||||
|
||||
// A pending reservation can be recovered after a process restart. An open
|
||||
// row is fenced back to paused before publishing the user turn; a running row
|
||||
// is stopped through the normal live-work protocol so no late writer races us.
|
||||
for {
|
||||
node, getErr := t.Store.GetNode(intentID)
|
||||
if getErr != nil {
|
||||
return out, getErr
|
||||
}
|
||||
if node == nil {
|
||||
return out, fmt.Errorf("%w: intent not found", errWorkInterventionConflict)
|
||||
}
|
||||
switch node.State {
|
||||
case "running":
|
||||
controlErr := e.ControlWork(ctx, intentID, "intervene")
|
||||
if controlErr != nil {
|
||||
// The per-work 30s wait may expire before the server-owned operation
|
||||
// context. Keep waiting for the named cancellation to settle; timeout
|
||||
// release marks the run no-handoff so a late exit cannot install a
|
||||
// permanent directed-claim barrier behind our back. A freshly claimed
|
||||
// intent can also be running just before registerWork; retrying closes
|
||||
// that small claim-to-registration window.
|
||||
for {
|
||||
settled, readErr := t.Store.GetNode(intentID)
|
||||
if readErr != nil {
|
||||
return out, readErr
|
||||
}
|
||||
if settled != nil && settled.State == "paused" {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil || settled == nil || settled.State != "running" {
|
||||
e.removeDirectedClaim(t.ID, intentID)
|
||||
return out, fmt.Errorf("%w: %v", errWorkInterventionConflict, controlErr)
|
||||
}
|
||||
if sleepCtx(ctx, 50*time.Millisecond) {
|
||||
e.removeDirectedClaim(t.ID, intentID)
|
||||
return out, fmt.Errorf("%w: %v", errWorkInterventionConflict, controlErr)
|
||||
}
|
||||
if errors.Is(controlErr, errWorkControlConflict) {
|
||||
controlErr = e.ControlWork(ctx, intentID, "intervene")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "open":
|
||||
changed, setErr := t.Store.CompareAndSetIntentState(intentID, "open", "paused")
|
||||
if setErr != nil {
|
||||
return out, setErr
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
e.queueDirectedClaim(t.ID, intentID)
|
||||
case "paused":
|
||||
e.queueDirectedClaim(t.ID, intentID)
|
||||
default:
|
||||
e.abandonWorkIntervention(t, intentID, requestID,
|
||||
fmt.Errorf("意图已进入 %s,无法发送新消息", node.State))
|
||||
return out, fmt.Errorf("%w: 意图已进入 %s,无法发送新消息", errWorkInterventionConflict, node.State)
|
||||
}
|
||||
break
|
||||
}
|
||||
e.queueDirectedClaim(t.ID, intentID)
|
||||
|
||||
// Close lifecycle races that began while cancellation was settling. A task
|
||||
// pause or queue preserves the pending message and paused intent for a later
|
||||
// admission; terminal, settling and deleting transitions close the request.
|
||||
if err := e.validateWorkInterventionTask(t, allowAdmissionPause); err != nil {
|
||||
// The user message has not entered the transcript yet. Preserve the
|
||||
// reservation and paused intent so the same request can be retried after a
|
||||
// transient task pause; do not reopen it behind a terminal transition.
|
||||
e.removeDirectedClaim(t.ID, intentID)
|
||||
lifecycle := t.lifecycleSnapshot()
|
||||
if e.IsDeleting(t.ID) || e.isSettling(t.ID) || isTerminalStatus(lifecycle.Status) {
|
||||
e.abandonWorkIntervention(t, intentID, requestID, err)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
activity, duplicate, err := t.Store.AcceptIntentIntervention(intentID, requestID, message)
|
||||
if err != nil {
|
||||
// Keep this intent paused so a retry of the same request can safely finish;
|
||||
// remove only the task-wide claim barrier so unrelated workers keep moving.
|
||||
e.removeDirectedClaim(t.ID, intentID)
|
||||
return out, err
|
||||
}
|
||||
if !duplicate {
|
||||
e.publishStoredActivity(t, activity)
|
||||
t.NotifyWorkerMessage(intentID, activity.ID, message)
|
||||
}
|
||||
e.readyDirectedClaim(intentID)
|
||||
t.NotifyWorker()
|
||||
node, err := t.Store.GetNode(intentID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Duplicate = duplicate
|
||||
if node != nil {
|
||||
out.State = node.State
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RecoverWorkerMessages reconstructs unfinished Worker chat handoffs before
|
||||
// ordinary frontier claiming. Pending messages are fenced in paused until an
|
||||
// agent runtime is available; accepted messages regain their directed priority
|
||||
// and are consumed by ExecuteWithMessage on the next claim.
|
||||
func (e *Engine) RecoverWorkerMessages(ctx context.Context, t *Task) error {
|
||||
return e.recoverWorkerMessages(ctx, t, false)
|
||||
}
|
||||
|
||||
// recoverWorkerMessagesForAdmission runs while the scheduler still owns the
|
||||
// Engine pause barrier but has already committed a runnable task lifecycle. It
|
||||
// restores directed hand-offs before Resume wakes any worker, so ordinary
|
||||
// frontier work cannot overtake the human message.
|
||||
func (e *Engine) recoverWorkerMessagesForAdmission(ctx context.Context, t *Task) error {
|
||||
return e.recoverWorkerMessages(ctx, t, true)
|
||||
}
|
||||
|
||||
func (e *Engine) recoverWorkerMessages(ctx context.Context, t *Task, allowAdmissionPause bool) error {
|
||||
if t == nil || t.Store == nil {
|
||||
return nil
|
||||
}
|
||||
items, err := t.Store.RecoverableIntentInterventions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lifecycle := t.lifecycleSnapshot()
|
||||
runnable := !e.IsDeleting(t.ID) && !e.isSettling(t.ID) && !isTerminalStatus(lifecycle.Status) &&
|
||||
!lifecycle.Paused && !lifecycle.Queued && (allowAdmissionPause || !e.IsPaused(t.ID))
|
||||
if !runnable {
|
||||
for _, item := range items {
|
||||
e.removeDirectedClaim(t.ID, item.IntentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case db.IntentInterventionPending:
|
||||
if item.IntentState == "open" {
|
||||
if changed, setErr := t.Store.CompareAndSetIntentState(item.IntentID, "open", "paused"); setErr != nil {
|
||||
return setErr
|
||||
} else if changed {
|
||||
item.IntentState = "paused"
|
||||
}
|
||||
}
|
||||
if _, worker := e.snapshotFor(t); worker == nil {
|
||||
continue
|
||||
}
|
||||
if _, recoverErr := e.interveneWork(ctx, t, item.IntentID, item.RequestID, item.Message, allowAdmissionPause); recoverErr != nil {
|
||||
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
|
||||
} else if changed {
|
||||
item.IntentState = "open"
|
||||
}
|
||||
}
|
||||
if item.IntentState == "open" {
|
||||
e.queueDirectedClaim(t.ID, item.IntentID)
|
||||
e.readyDirectedClaim(item.IntentID)
|
||||
t.NotifyWorker()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseWorkControl drops only this caller's reservation after its wait is
|
||||
// cancelled. The work context stays cancelled; runWorkerStep recognizes the
|
||||
// named cancellation cause and settles the intent into the recoverable paused
|
||||
@@ -471,7 +780,11 @@ func (e *Engine) ControlWork(ctx context.Context, intentID int64, action string)
|
||||
func (e *Engine) releaseWorkControl(intentID int64, run *workExecution, action string) {
|
||||
e.workMu.Lock()
|
||||
if current := e.work[intentID]; current == run && current.action == action {
|
||||
current.action = ""
|
||||
if action == "intervene" {
|
||||
current.action = "intervene_no_handoff"
|
||||
} else {
|
||||
current.action = ""
|
||||
}
|
||||
}
|
||||
e.workMu.Unlock()
|
||||
}
|
||||
@@ -610,6 +923,19 @@ func (e *Engine) emitActivity(t *Task, r db.Activity) db.Activity {
|
||||
return r
|
||||
}
|
||||
|
||||
// publishStoredActivity broadcasts an activity inserted by a larger DB
|
||||
// transaction, avoiding a second INSERT through emitActivity.
|
||||
func (e *Engine) publishStoredActivity(t *Task, r db.Activity) {
|
||||
if t == nil || r.ID <= 0 {
|
||||
return
|
||||
}
|
||||
if r.CreatedAt.IsZero() {
|
||||
r.CreatedAt = time.Now()
|
||||
}
|
||||
e.bc.Publish(t.ID, r)
|
||||
e.touch(t.ID)
|
||||
}
|
||||
|
||||
// appendActivity persists one activity row, retrying briefly on write failure.
|
||||
// Concurrent planner + worker inserts into the same exploration's activity log
|
||||
// occasionally fail; a couple of quick retries recover most. Crucially, every
|
||||
@@ -876,13 +1202,13 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
|
||||
}
|
||||
_, worker := e.snapshotFor(t)
|
||||
if worker == nil {
|
||||
if sleepCtx(ctx, 1500*time.Millisecond) {
|
||||
if waitWorker(ctx, t, 1500*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if e.IsPaused(t.ID) {
|
||||
if sleepCtx(ctx, 1000*time.Millisecond) {
|
||||
if waitWorker(ctx, t, 1000*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
continue // user-paused: don't claim/execute intents
|
||||
@@ -891,13 +1217,13 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
|
||||
return
|
||||
}
|
||||
if e.isSettling(t.ID) {
|
||||
if sleepCtx(ctx, 1000*time.Millisecond) {
|
||||
if waitWorker(ctx, t, 1000*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
continue // 任务超时收尾中:不再领新意图(在跑的自行收尾,协调器等其 drain)
|
||||
}
|
||||
if isTerminalStatus(e.m.TaskStatus(t.ID)) {
|
||||
if sleepCtx(ctx, 1000*time.Millisecond) {
|
||||
if waitWorker(ctx, t, 1000*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
continue // 任务已终态(done/failed/timeout):停止领取遗留意图,别在完成后空跑 frontier
|
||||
@@ -907,7 +1233,7 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
|
||||
}
|
||||
claimed := e.runWorkerStep(ctx, t, name, worker)
|
||||
e.decInflight(t.ID)
|
||||
if !claimed && sleepCtx(ctx, 800*time.Millisecond) {
|
||||
if !claimed && waitWorker(ctx, t, 800*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -946,9 +1272,31 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
workCtx = intercept.WithTaskContext(workCtx, t.ID, fmt.Sprintf("%s · #%d", name, iid), taskEmit)
|
||||
hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
|
||||
wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
chatRequestID, chatMessage, hasChatMessage, chatErr := t.Store.PendingIntentInterventionMessage(intent.ID)
|
||||
if chatErr != nil {
|
||||
log.Printf("[worker %s] task %s 读取意图 #%d 待处理人工对话失败: %v", name, t.ID, intent.ID, chatErr)
|
||||
if err := transitionIntentState(t.Store, intent.ID, "running", "open"); err != nil {
|
||||
log.Printf("[worker %s] task %s 意图 #%d 人工对话读取失败后回退失败: %v", name, t.ID, intent.ID, err)
|
||||
}
|
||||
_, completeWork := e.detachWork(intent.ID)
|
||||
completeWork(chatErr)
|
||||
return true
|
||||
}
|
||||
e.BeginLLMCall(t.ID)
|
||||
reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
|
||||
var reason harness.TerminalReason
|
||||
var wrote agent.WriteCounts
|
||||
var err error
|
||||
if hasChatMessage {
|
||||
reason, wrote, err = worker.ExecuteWithMessage(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding, chatRequestID, chatMessage)
|
||||
} else {
|
||||
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
|
||||
}
|
||||
e.EndLLMCall(t.ID)
|
||||
if hasChatMessage {
|
||||
if markErr := t.Store.MarkIntentInterventionHandoffClaimed(intent.ID); markErr != nil {
|
||||
log.Printf("[worker %s] task %s 标记意图 #%d 人工对话已消费失败: %v", name, t.ID, intent.ID, markErr)
|
||||
}
|
||||
}
|
||||
// model_error 收场 → 额外重跑几次(退避后再试)。仅在意图仍属本 work、任务
|
||||
// 未暂停/未终止/未取消【且未进入收尾】时重试;否则让位给对应分支处理(收尾期不
|
||||
// 再重试,避免退避挤占其他 worker 的优雅收尾窗口)。
|
||||
@@ -961,7 +1309,11 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
break // 退避期间被取消(终止/暂停)→ 交给下方分支处理
|
||||
}
|
||||
e.BeginLLMCall(t.ID)
|
||||
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
|
||||
if hasChatMessage {
|
||||
reason, wrote, err = worker.ExecuteWithMessage(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding, chatRequestID, chatMessage)
|
||||
} else {
|
||||
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
|
||||
}
|
||||
e.EndLLMCall(t.ID)
|
||||
}
|
||||
// Capture kill state before detachWork cancels workCtx. kill = this work's
|
||||
@@ -979,6 +1331,8 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
switch {
|
||||
case errors.Is(workCause, agent.AbortWorkPausedByUser):
|
||||
action = "pause"
|
||||
case errors.Is(workCause, agent.AbortWorkIntervenedByUser):
|
||||
action = "intervene"
|
||||
case errors.Is(workCause, agent.AbortWorkCancelledByUser):
|
||||
action = "cancel"
|
||||
}
|
||||
@@ -995,6 +1349,22 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
e.touch(t.ID)
|
||||
return true
|
||||
}
|
||||
if action == "intervene" || action == "intervene_no_handoff" {
|
||||
controlErr = transitionIntentState(t.Store, intent.ID, "running", "paused")
|
||||
if controlErr != nil {
|
||||
log.Printf("[worker %s] task %s 意图 #%d 对话暂停落库失败: %v", name, t.ID, intent.ID, controlErr)
|
||||
return true
|
||||
}
|
||||
// Install the directed barrier before completeWork releases ControlWork.
|
||||
// With one worker slot this prevents its next loop from taking unrelated
|
||||
// frontier work during the message hand-off.
|
||||
if action == "intervene" {
|
||||
e.queueDirectedClaim(t.ID, intent.ID)
|
||||
}
|
||||
log.Printf("[worker %s] task %s 意图 #%d 已暂停,等待新的对话意图", name, t.ID, intent.ID)
|
||||
e.touch(t.ID)
|
||||
return true
|
||||
}
|
||||
if action == "cancel" {
|
||||
// Park the stopped run in paused before handing cleanup to the API. If the
|
||||
// request disconnects after cancellation, the intent remains recoverable and
|
||||
@@ -1099,7 +1469,114 @@ func sleepCtx(ctx context.Context, d time.Duration) (done bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitWorker(ctx context.Context, t *Task, d time.Duration) (done bool) {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
var wake <-chan struct{}
|
||||
if t != nil {
|
||||
wake = t.workerWake
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
case <-wake:
|
||||
return false
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) queueDirectedClaim(taskID string, intentID int64) {
|
||||
if taskID == "" || intentID <= 0 {
|
||||
return
|
||||
}
|
||||
e.directedMu.Lock()
|
||||
defer e.directedMu.Unlock()
|
||||
for _, id := range e.directedClaims[taskID] {
|
||||
if id == intentID {
|
||||
return
|
||||
}
|
||||
}
|
||||
e.directedClaims[taskID] = append(e.directedClaims[taskID], intentID)
|
||||
e.directedReady[intentID] = false
|
||||
}
|
||||
|
||||
func (e *Engine) readyDirectedClaim(intentID int64) {
|
||||
e.directedMu.Lock()
|
||||
if _, exists := e.directedReady[intentID]; exists {
|
||||
e.directedReady[intentID] = true
|
||||
}
|
||||
e.directedMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Engine) removeDirectedClaim(taskID string, intentID int64) {
|
||||
e.directedMu.Lock()
|
||||
defer e.directedMu.Unlock()
|
||||
items := e.directedClaims[taskID]
|
||||
for i, id := range items {
|
||||
if id != intentID {
|
||||
continue
|
||||
}
|
||||
items = append(items[:i], items[i+1:]...)
|
||||
delete(e.directedReady, intentID)
|
||||
if len(items) == 0 {
|
||||
delete(e.directedClaims, taskID)
|
||||
} else {
|
||||
e.directedClaims[taskID] = items
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// claimDirected tries every pending human-message hand-off before ordinary work
|
||||
// frontier. blocked=true means at least one hand-off is still paused/running;
|
||||
// callers must not let a just-released single worker slot overtake it.
|
||||
func (e *Engine) claimDirected(t *Task, name string) (intent *db.Node, blocked bool) {
|
||||
e.directedMu.Lock()
|
||||
ids := append([]int64(nil), e.directedClaims[t.ID]...)
|
||||
ready := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
ready[id] = e.directedReady[id]
|
||||
}
|
||||
e.directedMu.Unlock()
|
||||
for _, id := range ids {
|
||||
node, err := t.Store.GetNode(id)
|
||||
if err != nil {
|
||||
return nil, true
|
||||
}
|
||||
if node == nil {
|
||||
e.removeDirectedClaim(t.ID, id)
|
||||
continue
|
||||
}
|
||||
switch node.State {
|
||||
case "open":
|
||||
if !ready[id] {
|
||||
blocked = true
|
||||
continue
|
||||
}
|
||||
ok, err := t.Store.ClaimIntent(id, name)
|
||||
if err != nil {
|
||||
return nil, true
|
||||
}
|
||||
if ok {
|
||||
e.removeDirectedClaim(t.ID, id)
|
||||
node.State, node.Owner = "running", name
|
||||
return node, false
|
||||
}
|
||||
blocked = true
|
||||
case "paused", "running":
|
||||
blocked = true
|
||||
default:
|
||||
e.removeDirectedClaim(t.ID, id)
|
||||
}
|
||||
}
|
||||
return nil, blocked
|
||||
}
|
||||
|
||||
func (e *Engine) claimNext(t *Task, name string) *db.Node {
|
||||
if intent, blocked := e.claimDirected(t, name); intent != nil || blocked {
|
||||
return intent
|
||||
}
|
||||
fr, _ := t.Store.Frontier(20)
|
||||
for _, in := range fr {
|
||||
if ok, _ := t.Store.ClaimIntent(in.ID, name); ok {
|
||||
|
||||
@@ -45,6 +45,7 @@ func TestControlWorkCarriesUserActionCause(t *testing.T) {
|
||||
}{
|
||||
{action: "pause", code: "work_paused_by_user"},
|
||||
{action: "cancel", code: "work_cancelled_by_user"},
|
||||
{action: "intervene", code: "work_intervened_by_user"},
|
||||
} {
|
||||
t.Run(tc.action, func(t *testing.T) {
|
||||
e := NewEngine(nil)
|
||||
|
||||
@@ -225,6 +225,13 @@ func (s *Server) hasReadyQueuedTask(excludeID string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) startAdmittedTask(t *Task, mode string) {
|
||||
recoverMessages := s.engine.RecoverWorkerMessages
|
||||
if s.engine.IsPaused(t.ID) {
|
||||
recoverMessages = s.engine.recoverWorkerMessagesForAdmission
|
||||
}
|
||||
if err := recoverMessages(s.ctx, t); err != nil {
|
||||
log.Printf("[worker chat] task %s 恢复待处理人工消息失败: %v", t.ID, err)
|
||||
}
|
||||
if mode == "bootstrap" {
|
||||
if !s.engine.beginTaskOperation(t.ID) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxWorkerMessageBytes = 64 << 10
|
||||
|
||||
func validWorkerMessageRequestID(id string) bool {
|
||||
if id == "" || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' || r == ':' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sendWorkerMessage pauses a running Worker when necessary, records one user
|
||||
// turn for its existing conversation, and immediately schedules that same
|
||||
// intent to continue. A disconnected browser cannot strand an accepted message.
|
||||
func (s *Server) sendWorkerMessage(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64)
|
||||
if err != nil || iid <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "bad intent id")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxWorkerMessageBytes)
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求体过大")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(req.Message)
|
||||
requestID := strings.TrimSpace(req.RequestID)
|
||||
if message == "" {
|
||||
writeErr(w, http.StatusBadRequest, "消息不能为空")
|
||||
return
|
||||
}
|
||||
if len([]rune(message)) > 4000 {
|
||||
writeErr(w, http.StatusBadRequest, "消息不能超过 4000 个字符")
|
||||
return
|
||||
}
|
||||
if !validWorkerMessageRequestID(requestID) {
|
||||
writeErr(w, http.StatusBadRequest, "request_id 必须是 1-128 位字母、数字、-、_、. 或 :")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.engine.beginTaskOperation(t.ID) {
|
||||
writeErr(w, http.StatusConflict, "任务正在删除,无法向 Worker 发送消息")
|
||||
return
|
||||
}
|
||||
defer s.engine.decInflight(t.ID)
|
||||
|
||||
node, err := t.Store.GetNode(iid)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if node == nil {
|
||||
if inherited, sourceErr := t.Store.GetNodeWithSources(iid); sourceErr == nil && inherited != nil && inherited.Inherited {
|
||||
writeErr(w, http.StatusConflict, "继承意图为只读,不能发送 Worker 消息")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusNotFound, "intent not found")
|
||||
return
|
||||
}
|
||||
if node.Kind != db.KindIntent {
|
||||
writeErr(w, http.StatusConflict, "node is not an intent")
|
||||
return
|
||||
}
|
||||
base := s.ctx
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
opCtx, cancel := context.WithTimeout(base, workControlWaitTimeout+15*time.Second)
|
||||
defer cancel()
|
||||
result, err := s.engine.InterveneWork(opCtx, t, iid, requestID, message)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
|
||||
writeErr(w, http.StatusGatewayTimeout, err.Error())
|
||||
case errors.Is(err, errWorkInterventionConflict), errors.Is(err, errWorkControlConflict),
|
||||
errors.Is(err, db.ErrIntentInterventionConflict), errors.Is(err, db.ErrIntentStateConflict):
|
||||
writeErr(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": iid,
|
||||
"state": result.State,
|
||||
"accepted": true,
|
||||
"activity_seq": result.ActivityID,
|
||||
"request_id": result.RequestID,
|
||||
"duplicate": result.Duplicate,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"iter"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
)
|
||||
|
||||
type workerMessageTestProvider struct{}
|
||||
|
||||
func (workerMessageTestProvider) Stream(_ context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "已按新的对话意图继续执行"}, nil)
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil)
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (p workerMessageTestProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
acc := llm.NewAccumulator()
|
||||
for event, err := range p.Stream(ctx, req) {
|
||||
if err != nil {
|
||||
return llm.Message{}, "", llm.Usage{}, err
|
||||
}
|
||||
acc.Add(event)
|
||||
}
|
||||
return acc.Message(), acc.StopReason, acc.Usage, nil
|
||||
}
|
||||
|
||||
type interventionProtocolFixture struct {
|
||||
m *Manager
|
||||
task *Task
|
||||
engine *Engine
|
||||
worker *agent.Worker
|
||||
tx *transcript.Store
|
||||
intentID int64
|
||||
}
|
||||
|
||||
func newInterventionProtocolFixture(t *testing.T, targetPriority int) *interventionProtocolFixture {
|
||||
t.Helper()
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
task, err := m.CreateTask("worker message protocol", "verify immediate conversation handoff", nil, 0, 0)
|
||||
if err != nil {
|
||||
m.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
intentID, err := task.Store.AddIntent(map[string]any{"summary": "target worker intent"}, targetPriority, nil, "planner")
|
||||
if err != nil {
|
||||
m.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claimed, err := task.Store.ClaimIntent(intentID, "work#1"); err != nil || !claimed {
|
||||
m.Close()
|
||||
t.Fatalf("claim target intent: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
tx := transcript.NewStore(m.dir + "/transcripts")
|
||||
worker := agent.NewWorker(workerMessageTestProvider{}, "test-model", m.dir, tx, 0, 1)
|
||||
engine := NewEngine(m)
|
||||
engine.UseLLM(nil, worker)
|
||||
fixture := &interventionProtocolFixture{m: m, task: task, engine: engine, worker: worker, tx: tx, intentID: intentID}
|
||||
t.Cleanup(func() {
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
_ = m.pg.DeleteTask(taskID)
|
||||
_ = m.Close()
|
||||
})
|
||||
return fixture
|
||||
}
|
||||
|
||||
// settleRegisteredIntervention emulates the ordering inside runWorkerStep after
|
||||
// a cancellation-aware provider returns: detach the live work handle, commit the
|
||||
// running->paused final state, install the directed hand-off, then release the
|
||||
// controller waiting on workExecution.done.
|
||||
func settleRegisteredIntervention(t *testing.T, f *interventionProtocolFixture) string {
|
||||
t.Helper()
|
||||
action, complete := f.engine.detachWork(f.intentID)
|
||||
if action != "intervene" {
|
||||
t.Fatalf("settled action=%q, want intervene", action)
|
||||
}
|
||||
if changed, err := f.task.Store.CompareAndSetIntentState(f.intentID, "running", "paused"); err != nil || !changed {
|
||||
t.Fatalf("settle running->paused: changed=%v err=%v", changed, err)
|
||||
}
|
||||
f.engine.queueDirectedClaim(f.task.ID, f.intentID)
|
||||
complete(nil)
|
||||
return action
|
||||
}
|
||||
|
||||
func countTranscriptWorkerMessages(t *testing.T, tx *transcript.Store, sessionID, requestID string) (int, []transcript.Record) {
|
||||
t.Helper()
|
||||
records, err := tx.Load(tx.MainPath(sessionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := "ARTEX_WORKER_CHAT:" + requestID
|
||||
count := 0
|
||||
for _, record := range records {
|
||||
if record.Message != nil && strings.Contains(record.Message.Text(), marker) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, records
|
||||
}
|
||||
|
||||
func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing.T) {
|
||||
f := newInterventionProtocolFixture(t, 1)
|
||||
competitorID, err := f.task.Store.AddIntent(map[string]any{"summary": "higher priority competitor"}, 100, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pre-seed the same stable session so the message must continue its UUID
|
||||
// chain rather than creating a worker-slot-specific conversation.
|
||||
sessionID := agent.WorkerSessionID(f.task.ExpID, f.intentID)
|
||||
seed := f.tx.NewWriter(sessionID, "")
|
||||
seed.RecordMessage(llm.UserText("original intent turn"), llm.Usage{})
|
||||
if err := seed.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := f.tx.Load(f.tx.MainPath(sessionID))
|
||||
if err != nil || len(before) != 1 {
|
||||
t.Fatalf("seed transcript records=%d err=%v", len(before), err)
|
||||
}
|
||||
|
||||
workCtx, cancelWork := context.WithCancelCause(context.Background())
|
||||
f.engine.registerWork(f.intentID, cancelWork)
|
||||
live, unsubscribe := f.engine.Broadcaster().Subscribe(f.task.ID)
|
||||
defer unsubscribe()
|
||||
|
||||
const requestID = "worker-message-engine-1"
|
||||
const message = "停止主动枚举,立即验证登录越权"
|
||||
type outcome struct {
|
||||
result WorkInterventionResult
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
result, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, message)
|
||||
done <- outcome{result: result, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-workCtx.Done():
|
||||
if code, _, _, _ := agent.AbortReason(workCtx); code != "work_intervened_by_user" {
|
||||
t.Fatalf("old execution cancel code=%q, want work_intervened_by_user", code)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Worker message did not immediately cancel the old execution")
|
||||
}
|
||||
select {
|
||||
case early := <-done:
|
||||
t.Fatalf("Worker message accepted before old worker settled: %+v", early)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
pending, err := f.task.Store.IntentInterventionByRequest(f.intentID, requestID)
|
||||
if err != nil || pending == nil || pending.Status != db.IntentInterventionPending {
|
||||
t.Fatalf("durable pending reservation=%+v err=%v", pending, err)
|
||||
}
|
||||
settleRegisteredIntervention(t, f)
|
||||
|
||||
var accepted outcome
|
||||
select {
|
||||
case accepted = <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for accepted Worker message")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// The API publishes the visible user activity but never edits transcript files.
|
||||
// agentcore appends the standard user turn only after the same Worker is claimed.
|
||||
count, records := countTranscriptWorkerMessages(t, f.tx, sessionID, requestID)
|
||||
if count != 0 || len(records) != 1 {
|
||||
t.Fatalf("HTTP handler edited Worker transcript: count=%d records=%+v", count, records)
|
||||
}
|
||||
select {
|
||||
case activity := <-live:
|
||||
if activity.ID != accepted.result.ActivityID || activity.NodeID == nil || *activity.NodeID != f.intentID ||
|
||||
activity.Worker != "user" || activity.Kind != "user" || activity.Summary != message {
|
||||
t.Fatalf("broadcast activity=%+v", activity)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("accepted Worker message was not broadcast")
|
||||
}
|
||||
select {
|
||||
case duplicate := <-live:
|
||||
t.Fatalf("accepted Worker message broadcast twice: %+v", duplicate)
|
||||
default:
|
||||
}
|
||||
|
||||
// The directed hand-off must outrank the normal frontier without changing the
|
||||
// persisted priority. This catches the one-worker race where the released slot
|
||||
// used to take an unrelated, higher-priority intent before the target reopened.
|
||||
claimed := f.engine.claimNext(f.task, "work#2")
|
||||
if claimed == nil || claimed.ID != f.intentID {
|
||||
t.Fatalf("directed claim=%+v, want target %d ahead of competitor %d", claimed, f.intentID, competitorID)
|
||||
}
|
||||
competitor, err := f.task.Store.GetNode(competitorID)
|
||||
if err != nil || competitor == nil || competitor.State != "open" {
|
||||
t.Fatalf("competitor was claimed before Worker message target: node=%+v err=%v", competitor, err)
|
||||
}
|
||||
request, nextMessage, hasMessage, err := f.task.Store.PendingIntentInterventionMessage(f.intentID)
|
||||
if err != nil || !hasMessage || request != requestID || nextMessage != message {
|
||||
t.Fatalf("pending Worker message request=%q message=%q present=%v err=%v", request, nextMessage, hasMessage, err)
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(f.task.ID, 10, 64)
|
||||
if _, _, err := f.worker.ExecuteWithMessage(context.Background(), "work#2", taskID, nil, f.task.Store,
|
||||
claimed, nil, nil, nil, nil, request, nextMessage); err != nil {
|
||||
t.Fatalf("continue Worker conversation: %v", err)
|
||||
}
|
||||
if err := f.task.Store.MarkIntentInterventionHandoffClaimed(f.intentID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, records = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID)
|
||||
if count != 1 || len(records) != 3 || records[1].ParentUUID != records[0].UUID || records[2].ParentUUID != records[1].UUID {
|
||||
t.Fatalf("Worker message did not form one normal chained turn: count=%d records=%+v", count, records)
|
||||
}
|
||||
if records[1].Message == nil || records[1].Message.Role != llm.RoleUser || !strings.Contains(records[1].Message.Text(), message) {
|
||||
t.Fatalf("Worker message was not recorded as the next user turn: %+v", records[1])
|
||||
}
|
||||
if _, _, pendingAfterRun, err := f.task.Store.PendingIntentInterventionMessage(f.intentID); err != nil || pendingAfterRun {
|
||||
t.Fatalf("Worker message handoff remains pending=%v err=%v", pendingAfterRun, err)
|
||||
}
|
||||
|
||||
retry, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, message)
|
||||
if err != nil || !retry.Duplicate || retry.ActivityID != accepted.result.ActivityID {
|
||||
t.Fatalf("idempotent engine retry=%+v err=%v", retry, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
count, _ = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID)
|
||||
if count != 1 {
|
||||
t.Fatalf("engine retries appended %d Worker messages, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWorkerMessageContinuesAfterRequestContextCancellation(t *testing.T) {
|
||||
f := newInterventionProtocolFixture(t, 1)
|
||||
workCtx, cancelWork := context.WithCancelCause(context.Background())
|
||||
f.engine.registerWork(f.intentID, cancelWork)
|
||||
s := &Server{m: f.m, engine: f.engine, ctx: context.Background()}
|
||||
|
||||
requestBody, _ := json.Marshal(map[string]string{
|
||||
"message": "中止当前动作,只验证权限边界", "request_id": "worker-message-disconnect-1",
|
||||
})
|
||||
requestCtx, disconnect := context.WithCancel(context.Background())
|
||||
disconnect()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tasks/1/intents/1/messages", bytes.NewReader(requestBody)).WithContext(requestCtx)
|
||||
req.SetPathValue("id", f.task.ID)
|
||||
req.SetPathValue("iid", strconv.FormatInt(f.intentID, 10))
|
||||
recorder := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.sendWorkerMessage(recorder, req)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-workCtx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("disconnected request did not continue to cancel the worker")
|
||||
}
|
||||
settleRegisteredIntervention(t, f)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server-owned Worker message did not finish after browser disconnect")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("disconnected Worker message status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
accepted, err := f.task.Store.IntentInterventionByRequest(f.intentID, "worker-message-disconnect-1")
|
||||
if err != nil || accepted == nil || accepted.Status != db.IntentInterventionAccepted {
|
||||
t.Fatalf("disconnected request acceptance=%+v err=%v", accepted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerMessageRejectsUnavailableTaskStatesWithoutReservation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
block func(*interventionProtocolFixture)
|
||||
}{
|
||||
{name: "paused", block: func(f *interventionProtocolFixture) {
|
||||
f.task.updateLifecycle(func(state *taskLifecycleState) { state.Paused = true })
|
||||
f.engine.Pause(f.task.ID, agent.AbortPausedByUser)
|
||||
}},
|
||||
{name: "queued", block: func(f *interventionProtocolFixture) {
|
||||
f.task.updateLifecycle(func(state *taskLifecycleState) { state.Queued = true })
|
||||
}},
|
||||
{name: "terminal", block: func(f *interventionProtocolFixture) {
|
||||
f.task.updateLifecycle(func(state *taskLifecycleState) { state.Status = "done" })
|
||||
}},
|
||||
{name: "settling", block: func(f *interventionProtocolFixture) { f.engine.markSettling(f.task.ID) }},
|
||||
{name: "deleting", block: func(f *interventionProtocolFixture) { f.engine.BeginDelete(f.task.ID) }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newInterventionProtocolFixture(t, 1)
|
||||
tc.block(f)
|
||||
requestID := "worker-message-unavailable-" + tc.name
|
||||
if _, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, "must not persist"); !errors.Is(err, errWorkInterventionConflict) {
|
||||
t.Fatalf("unavailable state error=%v, want conflict", err)
|
||||
}
|
||||
reservation, err := f.task.Store.IntentInterventionByRequest(f.intentID, requestID)
|
||||
if err != nil || reservation != nil {
|
||||
t.Fatalf("unavailable state persisted reservation=%+v err=%v", reservation, err)
|
||||
}
|
||||
node, err := f.task.Store.GetNode(f.intentID)
|
||||
if err != nil || node == nil || node.State != "running" {
|
||||
t.Fatalf("unavailable state mutated intent=%+v err=%v", node, err)
|
||||
}
|
||||
count, _ := countTranscriptWorkerMessages(t, f.tx, agent.WorkerSessionID(f.task.ExpID, f.intentID), requestID)
|
||||
if count != 0 {
|
||||
t.Fatalf("unavailable state appended %d transcript interventions", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerWakeInterruptsLongIdlePoll(t *testing.T) {
|
||||
task := &Task{workerWake: make(chan struct{}, 1)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
returned := make(chan bool, 1)
|
||||
go func() {
|
||||
close(started)
|
||||
returned <- waitWorker(ctx, task, 5*time.Second)
|
||||
}()
|
||||
<-started
|
||||
start := time.Now()
|
||||
task.NotifyWorker()
|
||||
select {
|
||||
case stopped := <-returned:
|
||||
if stopped {
|
||||
t.Fatal("worker wake was mistaken for context shutdown")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
|
||||
t.Fatalf("worker wake took %s, want immediate wake", elapsed)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker wake waited for the idle polling interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWorkerMessageRejectsOversizedBodyAsEntityTooLarge(t *testing.T) {
|
||||
f := newInterventionProtocolFixture(t, 1)
|
||||
s := &Server{m: f.m, engine: f.engine, ctx: context.Background()}
|
||||
body := `{"message":"` + strings.Repeat("x", maxWorkerMessageBytes) + `","request_id":"oversized"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tasks/1/intents/1/messages", strings.NewReader(body))
|
||||
req.SetPathValue("id", f.task.ID)
|
||||
req.SetPathValue("iid", strconv.FormatInt(f.intentID, 10))
|
||||
recorder := httptest.NewRecorder()
|
||||
s.sendWorkerMessage(recorder, req)
|
||||
if recorder.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized Worker message status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -63,6 +63,7 @@ type Task struct {
|
||||
Store *pgdb.ExplorationStore `json:"-"`
|
||||
Guard *guard.Guard `json:"-"`
|
||||
notify chan struct{}
|
||||
workerWake chan struct{}
|
||||
lifecycleMu sync.RWMutex
|
||||
llmMu sync.RWMutex
|
||||
|
||||
@@ -737,7 +738,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: pt.CoverageEnabled,
|
||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
|
||||
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), workerWake: make(chan struct{}, 64),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1638,6 +1639,42 @@ 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.
|
||||
func (t *Task) NotifyWorker() {
|
||||
if t == nil || t.workerWake == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case t.workerWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -249,6 +249,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
if n, _ := t.Store.ResetRunningIntents(); n > 0 {
|
||||
log.Printf("[engine] task %s 重置 %d 个残留 running 意图为 open", t.ID, n)
|
||||
}
|
||||
if err := s.engine.RecoverWorkerMessages(ctx, t); err != nil {
|
||||
log.Printf("[worker chat] task %s 恢复待处理人工消息失败: %v", t.ID, err)
|
||||
}
|
||||
if lifecycle.Paused {
|
||||
s.engine.Pause(t.ID, agent.AbortPausedOnReload)
|
||||
}
|
||||
@@ -471,6 +474,11 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
s.llmOn = true
|
||||
s.cfgMu.Unlock()
|
||||
s.invalidateTaskAgents()
|
||||
for _, task := range s.m.List() {
|
||||
if err := s.engine.RecoverWorkerMessages(s.ctx, task); err != nil {
|
||||
log.Printf("[worker chat] task %s 在 LLM 就绪后恢复待处理人工消息失败: %v", task.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// wake the active task so a task created while idle starts exploring.
|
||||
if t := s.m.ActiveTask(); t != nil {
|
||||
@@ -749,6 +757,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/messages", s.sendWorkerMessage)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
|
||||
mux.HandleFunc("POST /api/active", s.setActive)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
RadioIcon,
|
||||
RotateCwIcon,
|
||||
ShieldAlertIcon,
|
||||
@@ -43,6 +42,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
@@ -93,6 +93,18 @@ const PAGE = 200; // history page size
|
||||
const SYSTEM_SCAN_PAGE = 500; // generic activity pages scanned to recover sparse system audit events
|
||||
const MAX_KEEP = 4000; // per-session in-memory cap; older pages re-fetched on scroll-up
|
||||
const STREAM_WINDOW_MS = 5000; // "live" = activity seen within this window
|
||||
const MAX_WORKER_MESSAGE_CHARS = 4000;
|
||||
|
||||
function newWorkerMessageRequestID(): string {
|
||||
return (
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`worker-message-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
|
||||
);
|
||||
}
|
||||
|
||||
function workerMessageCharCount(value: string): number {
|
||||
return Array.from(value).length;
|
||||
}
|
||||
|
||||
// One session's lazily-loaded, reverse-paginated cache. `lastTs`/`unread` are kept
|
||||
// live even for sessions that were never opened, so the list shows liveness + unread
|
||||
@@ -313,8 +325,6 @@ function SessionItem({
|
||||
hasPending,
|
||||
unread,
|
||||
onClick,
|
||||
onPause,
|
||||
onResume,
|
||||
onCancel,
|
||||
controlling,
|
||||
deleted,
|
||||
@@ -325,8 +335,6 @@ function SessionItem({
|
||||
hasPending?: boolean;
|
||||
unread?: number;
|
||||
onClick: () => void;
|
||||
onPause?: () => void;
|
||||
onResume?: () => void;
|
||||
onCancel?: () => void;
|
||||
controlling?: boolean;
|
||||
deleted?: boolean;
|
||||
@@ -339,7 +347,8 @@ function SessionItem({
|
||||
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
|
||||
) : null;
|
||||
|
||||
const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused");
|
||||
const cancellable =
|
||||
s.role === "worker" && !s.inherited && !deleted && (s.status === "running" || s.status === "paused");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -386,33 +395,8 @@ function SessionItem({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{controllable && (
|
||||
{cancellable && (
|
||||
<div className="flex shrink-0 items-center gap-0.5 pr-1 opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover/session:opacity-100 sm:group-focus-within/session:opacity-100">
|
||||
{s.status === "running" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onPause}
|
||||
disabled={controlling}
|
||||
title="暂停 Worker"
|
||||
aria-label="暂停 Worker"
|
||||
>
|
||||
{controlling ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onResume}
|
||||
disabled={controlling}
|
||||
title="恢复 Worker"
|
||||
aria-label="恢复 Worker"
|
||||
>
|
||||
{controlling ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -495,6 +479,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
|
||||
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
|
||||
const [cancelReason, setCancelReason] = React.useState("");
|
||||
const [workerMessage, setWorkerMessage] = React.useState("");
|
||||
const [workerMessageRequestId, setWorkerMessageRequestId] = React.useState("");
|
||||
const [workerMessageSending, setWorkerMessageSending] = React.useState(false);
|
||||
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
|
||||
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
@@ -554,6 +541,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
},
|
||||
[controllingIntent, patchIntentState, taskId],
|
||||
);
|
||||
|
||||
// SSE connection state — surfaced so a dropped realtime link is visible, never
|
||||
// silently shown as "no messages".
|
||||
const [sseLive, setSseLive] = React.useState(false);
|
||||
@@ -1345,6 +1333,55 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
.finally(() => setSending(false));
|
||||
}
|
||||
|
||||
function sendWorkerChat() {
|
||||
const intentId = active.intent_id;
|
||||
const message = workerMessage.trim();
|
||||
if (
|
||||
!intentId ||
|
||||
active.inherited ||
|
||||
(active.status !== "paused" && active.status !== "pending") ||
|
||||
workerMessageSending ||
|
||||
!message
|
||||
)
|
||||
return;
|
||||
if (workerMessageCharCount(message) > MAX_WORKER_MESSAGE_CHARS) {
|
||||
toast.error(`消息不能超过 ${MAX_WORKER_MESSAGE_CHARS} 个字符`);
|
||||
return;
|
||||
}
|
||||
const requestId = workerMessageRequestId || newWorkerMessageRequestID();
|
||||
if (!workerMessageRequestId) setWorkerMessageRequestId(requestId);
|
||||
setWorkerMessageSending(true);
|
||||
api
|
||||
.sendWorkerMessage(taskId, intentId, message, requestId)
|
||||
.then((result) => {
|
||||
const sentAt = new Date().toISOString();
|
||||
const activity: Activity = {
|
||||
seq: result.activity_seq,
|
||||
intent_id: intentId,
|
||||
worker: "user",
|
||||
ts: sentAt,
|
||||
kind: "user",
|
||||
summary: message,
|
||||
detail: message,
|
||||
};
|
||||
patchStore(`intent:${intentId}`, (state) => ({
|
||||
...state,
|
||||
items: mergeBySeq(state.items, [activity]),
|
||||
lastTs: sentAt,
|
||||
loaded: true,
|
||||
unread: 0,
|
||||
}));
|
||||
patchIntentState(intentId, result.state);
|
||||
setWorkerMessage("");
|
||||
setWorkerMessageRequestId("");
|
||||
toast.success(`消息已发送给 Worker #${intentId},已立即继续执行`);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
})
|
||||
.finally(() => setWorkerMessageSending(false));
|
||||
}
|
||||
|
||||
const mainLoaded = !!store.main?.loaded;
|
||||
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
|
||||
const sendMode = useChatSendMode();
|
||||
@@ -1475,11 +1512,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
onClick={() => {
|
||||
setActiveId(s.id);
|
||||
setListOpen(false); // 手机端选完即收起,把高度还给会话记录
|
||||
setWorkerMessage("");
|
||||
setWorkerMessageRequestId("");
|
||||
}}
|
||||
controlling={controllingIntent === s.intent_id}
|
||||
deleted={meta?.deleted}
|
||||
onPause={() => void controlWorker(s, "pause")}
|
||||
onResume={() => void controlWorker(s, "resume")}
|
||||
onCancel={() => {
|
||||
setCancelIntent(s);
|
||||
}}
|
||||
@@ -1749,6 +1786,74 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
) : active.role === "worker" &&
|
||||
!active.inherited &&
|
||||
(active.status === "running" || active.status === "paused" || active.status === "pending") ? (
|
||||
<div className="border-t p-3">
|
||||
<InputGroup className="min-h-9 has-disabled:opacity-100">
|
||||
<InputGroupTextarea
|
||||
rows={1}
|
||||
aria-label={`给 Worker #${active.intent_id} 发消息`}
|
||||
placeholder={`给 Worker #${active.intent_id} 发消息,调整执行方向…`}
|
||||
value={workerMessage}
|
||||
onChange={(event) => {
|
||||
setWorkerMessage(event.target.value);
|
||||
setWorkerMessageRequestId("");
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!shouldSubmitOnKey(event, sendMode)) return;
|
||||
event.preventDefault();
|
||||
sendWorkerChat();
|
||||
}}
|
||||
disabled={active.status === "running" || workerMessageSending}
|
||||
aria-invalid={workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS}
|
||||
className="max-h-36 min-h-9 overflow-y-auto"
|
||||
/>
|
||||
<InputGroupAddon align="block-end">
|
||||
<span
|
||||
className={cn(
|
||||
"px-1 text-[10px] tabular-nums text-muted-foreground",
|
||||
workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS}
|
||||
</span>
|
||||
{active.status === "running" ? (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
size="icon-xs"
|
||||
variant="destructive"
|
||||
onClick={() => void controlWorker(active, "pause")}
|
||||
disabled={controllingIntent === active.intent_id}
|
||||
title="暂停当前 Worker"
|
||||
aria-label="暂停当前 Worker"
|
||||
>
|
||||
{controllingIntent === active.intent_id ? (
|
||||
<Loader2Icon className="animate-spin" />
|
||||
) : (
|
||||
<SquareIcon />
|
||||
)}
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
size="icon-xs"
|
||||
variant="default"
|
||||
onClick={sendWorkerChat}
|
||||
disabled={
|
||||
workerMessageSending ||
|
||||
!workerMessage.trim() ||
|
||||
workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS
|
||||
}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{workerMessageSending ? <Spinner /> : <ArrowUpIcon />}
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center border-t px-4 py-2">
|
||||
<TodoPopover
|
||||
|
||||
@@ -284,6 +284,14 @@ export const api = {
|
||||
state: "paused" | "open" | "stopped";
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
|
||||
sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) =>
|
||||
post<{
|
||||
id: number;
|
||||
state: "open" | "running" | "paused";
|
||||
accepted: true;
|
||||
activity_seq: number;
|
||||
request_id: string;
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/messages`, { message, request_id: requestId }),
|
||||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeCompanyScopeValue,
|
||||
} from "../company-scope";
|
||||
import type {
|
||||
Activity,
|
||||
ArchiveBatchItem,
|
||||
Asset,
|
||||
BatchControlItem,
|
||||
@@ -42,6 +43,7 @@ const mockConversations = structuredClone(D.conversations);
|
||||
const mockIntents = structuredClone(D.intents);
|
||||
const mockCompanies = structuredClone(D.companies);
|
||||
const mockAssets = structuredClone(D.assets);
|
||||
const mockActivity = structuredClone(D.activity);
|
||||
type MockTaskArchiveSnapshot = {
|
||||
task: Task;
|
||||
numericTaskID: number;
|
||||
@@ -420,6 +422,15 @@ function bodyIDs(value: unknown): string[] {
|
||||
}
|
||||
|
||||
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
|
||||
type MockWorkerMessage = {
|
||||
intentId: string;
|
||||
message: string;
|
||||
state: "open" | "running";
|
||||
activitySeq: number;
|
||||
};
|
||||
|
||||
const mockWorkerMessages = new Map<string, MockWorkerMessage>();
|
||||
let nextMockWorkerMessageActivitySeq = mockActivity.reduce((maximum, item) => Math.max(maximum, item.seq), 0) + 1;
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
|
||||
const intent = mockIntents.find((item) => item.id === id);
|
||||
@@ -432,6 +443,71 @@ function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentCo
|
||||
return { ok: true, state: intent.state };
|
||||
}
|
||||
|
||||
function sendMockWorkerMessage(
|
||||
id: string,
|
||||
message: string,
|
||||
requestId: string,
|
||||
): MockIntentControlResult & { activitySeq?: number; requestId?: string } {
|
||||
const normalizedMessage = message.trim();
|
||||
const normalizedRequestId = requestId.trim();
|
||||
if (!normalizedRequestId) return { ok: false, error: "request_id 不能为空" };
|
||||
const previous = mockWorkerMessages.get(normalizedRequestId);
|
||||
if (previous) {
|
||||
if (previous.intentId !== id || previous.message !== normalizedMessage) {
|
||||
return { ok: false, error: "request_id 已用于其他 Worker 消息" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: previous.state,
|
||||
activitySeq: previous.activitySeq,
|
||||
requestId: normalizedRequestId,
|
||||
};
|
||||
}
|
||||
const intent = mockIntents.find((item) => item.id === id);
|
||||
if (!intent) return { ok: false, error: "意图不存在" };
|
||||
if (intent.inherited || (intent.state !== "running" && intent.state !== "paused")) {
|
||||
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
}
|
||||
if (!normalizedMessage) return { ok: false, state: intent.state, error: "消息不能为空" };
|
||||
if (Array.from(normalizedMessage).length > 4000) {
|
||||
return { ok: false, state: intent.state, error: "消息不能超过 4000 个字符" };
|
||||
}
|
||||
|
||||
// The real endpoint stops the active turn, persists a normal user message,
|
||||
// and immediately reopens the same intent. Keep the mock's transition in sync,
|
||||
// then emulate a Worker reclaiming it shortly afterward.
|
||||
intent.state = "open";
|
||||
const activitySeq = nextMockWorkerMessageActivitySeq++;
|
||||
const workerMessage: MockWorkerMessage = {
|
||||
intentId: id,
|
||||
message: normalizedMessage,
|
||||
state: "open",
|
||||
activitySeq,
|
||||
};
|
||||
mockWorkerMessages.set(normalizedRequestId, workerMessage);
|
||||
mockActivity.push({
|
||||
seq: activitySeq,
|
||||
intent_id: id,
|
||||
worker: "user",
|
||||
ts: new Date().toISOString(),
|
||||
kind: "user",
|
||||
summary: normalizedMessage,
|
||||
detail: normalizedMessage,
|
||||
} satisfies Activity);
|
||||
setTimeout(() => {
|
||||
if (intent.state === "open") {
|
||||
intent.state = "running";
|
||||
workerMessage.state = "running";
|
||||
}
|
||||
}, 250);
|
||||
return {
|
||||
ok: true,
|
||||
state: workerMessage.state,
|
||||
activitySeq: workerMessage.activitySeq,
|
||||
requestId: normalizedRequestId,
|
||||
};
|
||||
}
|
||||
|
||||
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
const task = mockTasks.find((item) => item.id === id);
|
||||
if (!task) return { id, ok: false, error: "任务不存在" };
|
||||
@@ -917,7 +993,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
intents: 1,
|
||||
facts: 1,
|
||||
findings: 1,
|
||||
activities: D.activity.filter((item) => item.intent_id === id).length,
|
||||
activities: mockActivity.filter((item) => item.intent_id === id).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -926,6 +1002,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
|
||||
return { id: Number(id.replace(/\D/g, "")) || 0, state: result.state };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "messages" && m === "POST") {
|
||||
const id = seg[3];
|
||||
const result = sendMockWorkerMessage(id, String(b.message ?? ""), String(b.request_id ?? ""));
|
||||
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
|
||||
return {
|
||||
id: Number(id.replace(/\D/g, "")) || 0,
|
||||
state: result.state ?? "open",
|
||||
accepted: true,
|
||||
activity_seq: result.activitySeq ?? 0,
|
||||
request_id: result.requestId ?? "",
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 3 && seg[2] === "control" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
const result = controlMockTask(seg[1], action);
|
||||
@@ -1464,13 +1552,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
const items = D.activityForTask()
|
||||
.filter((item) => item.seq > since)
|
||||
.slice(0, limit);
|
||||
const items = mockActivity.filter((item) => item.seq > since).slice(0, limit);
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : since };
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
|
||||
const a = D.activity.find((x) => x.seq === Number(seg[2]));
|
||||
const a = mockActivity.find((x) => x.seq === Number(seg[2]));
|
||||
return { detail: a?.detail ?? a?.summary ?? "" };
|
||||
}
|
||||
if (path === "/tokens/daily") return D.dailyTokens;
|
||||
|
||||
Reference in new issue
Block a user