重构 worker 对话:复用 resume 机制,改专属 goroutine 运行
把「给运行中 Worker 发消息调方向」从自建的介入持久化协议,改为复用 已有的暂停/恢复 + transcript resume 能力,实现与主 Agent chat 一致。 - 拆 runWorkerStep:抽出 runIntent(执行+善后),池子与消息路径共用一份 善后逻辑;新增 runDetachedIntent,把意图 paused→running(不经 frontier) 并在专属 goroutine 里直接跑,用户消息不受 3 槽池限制、随发随跑。 - 精简 sendWorkerMessage handler:校验 + emit 用户活动 + 专属运行;消息经 ExecuteWithMessage 作为下一轮输入注入,request_id 仅做 transcript 去重。 - 删除介入 DB 协议与 15 处 activity 查询过滤(回退 exploration.go)、 directedClaims 调度屏障、RecoverWorkerMessages 恢复、workerWake/waitWorker、 AbortWorkIntervenedByUser。无 DB schema 改动。 - 前端:保留消息框,恢复「直接继续」(resume)按钮;发送改由 SSE 承载, 去掉乐观插入。 - 取舍(低频可接受):消息仅存内存,不做崩溃恢复;发消息时该任务可能瞬时 超并发 1 个 worker。 净改动 9 文件 +583/-46(原 PR 15 文件 +1940/-72)。
This commit is contained in:
1 parent
ff493ae42b
commit
c9bf534818
14 files changed
+237
-1568
No files matched your search
+18
-394
@@ -14,11 +14,6 @@ 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
|
||||
@@ -335,376 +330,6 @@ 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
"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
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1381,8 +1006,7 @@ 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 WHERE kind IS DISTINCT FROM 'intervention_control' GROUP BY exploration_id`)
|
||||
rows, err := d.Query(`SELECT exploration_id, EXTRACT(EPOCH FROM MAX(created_at))::bigint FROM activity GROUP BY exploration_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1433,7 +1057,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 AND kind IS DISTINCT FROM 'intervention_control'
|
||||
WHERE exploration_id=task.exploration_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) latest_activity ON true
|
||||
@@ -1564,10 +1188,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 kind IS DISTINCT FROM 'intervention_control' AND id>$3 ORDER BY id LIMIT $4`, s.expID, *nodeID, sinceID, limit)
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 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 kind IS DISTINCT FROM 'intervention_control' AND id>$2 ORDER BY id LIMIT $3`, s.expID, sinceID, limit)
|
||||
FROM activity WHERE exploration_id=$1 AND id>$2 ORDER BY id LIMIT $3`, s.expID, sinceID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, sinceID, err
|
||||
@@ -1604,7 +1228,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','intervention_control')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
ORDER BY a.id LIMIT $4`, s.expID, nodeID, sinceID, limit)
|
||||
if err != nil {
|
||||
return nil, sinceID, err
|
||||
@@ -1667,7 +1291,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 AND kind IS DISTINCT FROM 'intervention_control'%s AND ($%d <= 0 OR id < $%d)
|
||||
FROM activity WHERE exploration_id=$1%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 {
|
||||
@@ -1712,7 +1336,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','intervention_control')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND ($3 <= 0 OR a.id < $3)
|
||||
ORDER BY a.id DESC LIMIT $4`, s.expID, nodeID, before, limit+1)
|
||||
if err != nil {
|
||||
@@ -1748,7 +1372,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 AND kind IS DISTINCT FROM 'intervention_control'`, s.expID).Scan(&max)
|
||||
err := s.db.QueryRow(`SELECT MAX(id) FROM activity WHERE exploration_id=$1`, s.expID).Scan(&max)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1758,7 +1382,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 AND kind IS DISTINCT FROM 'intervention_control'`, id, s.expID).Scan(&d)
|
||||
err := s.db.QueryRow(`SELECT detail FROM activity WHERE id=$1 AND exploration_id=$2`, id, s.expID).Scan(&d)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
@@ -1791,7 +1415,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','intervention_control')
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind NOT IN ('thinking','usage')
|
||||
ORDER BY id LIMIT $3`, s.expID, nodeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1811,7 +1435,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','intervention_control')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
ORDER BY a.id LIMIT $3`, s.expID, nodeID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1833,11 +1457,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','intervention_control')
|
||||
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND kind NOT IN ('thinking','usage')
|
||||
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','intervention_control')
|
||||
FROM activity WHERE exploration_id=$1 AND node_id IS NOT NULL AND kind NOT IN ('thinking','usage')
|
||||
AND (summary ILIKE $2 OR detail ILIKE $2) ORDER BY id LIMIT $3`, s.expID, like, limit)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1859,7 +1483,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','intervention_control')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND (a.summary ILIKE $3 OR a.detail ILIKE $3)
|
||||
ORDER BY a.id LIMIT $4`, s.expID, nodeID, like, limit)
|
||||
if err != nil {
|
||||
@@ -1881,7 +1505,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','intervention_control')
|
||||
AND a.kind NOT IN ('thinking','usage')
|
||||
AND (a.summary ILIKE $2 OR a.detail ILIKE $2)
|
||||
ORDER BY a.id LIMIT $3`, s.expID, like, limit)
|
||||
if err != nil {
|
||||
@@ -1952,7 +1576,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','intervention_control')
|
||||
AND kind NOT IN ('thinking','usage')
|
||||
AND (summary ILIKE $3 OR detail ILIKE $3) ORDER BY id LIMIT $4`, s.expID, excludeNodeID, like, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1976,7 +1600,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 NOT IN ('thinking','intervention_control') AND id IN (`+strings.Join(ph, ",")+`) ORDER BY id`, args...)
|
||||
FROM activity WHERE exploration_id=$1 AND kind<>'thinking' AND id IN (`+strings.Join(ph, ",")+`) ORDER BY id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2010,7 +1634,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','intervention_control') AND n.kind='intent'
|
||||
WHERE a.exploration_id=$1 AND a.kind NOT IN ('thinking','usage') 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 {
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user