From 01c6d73614a8f5f988937b2913debd48e73c62c9 Mon Sep 17 00:00:00 2001 From: pphua Date: Fri, 28 Aug 2026 15:20:52 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E5=A4=A7=E5=9E=8B=E4=BB=BB=E5=8A=A1=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/commands.go | 16 ++- db/db.go | 99 ++++++++++++---- db/db_test.go | 47 ++++++++ db/llm_usage.go | 14 ++- db/schema.sql | 3 +- db/task_archives.go | 161 ++++++++++++++++++++++--- db/task_archives_restore.go | 101 +++++++++++++++- db/task_archives_test.go | 178 +++++++++++++++++++++++++++- server/task_archive_package.go | 13 +- server/task_archive_package_test.go | 16 ++- server/task_archives.go | 63 +++++++++- 11 files changed, 651 insertions(+), 60 deletions(-) diff --git a/db/commands.go b/db/commands.go index 27172da..9e6e2e6 100644 --- a/db/commands.go +++ b/db/commands.go @@ -133,11 +133,21 @@ ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT; // EnsureLLMRecordsTable creates the llm_records table if it does not exist. func (d *DB) EnsureLLMRecordsTable() error { - if _, err := d.Exec(llmRecordsSchema); err != nil { + tx, err := d.Begin() + if err != nil { return err } - _, err := d.Exec(llmRecordsMigrate) - return err + defer tx.Rollback() //nolint:errcheck + if err := coordinateWithSchemaMigration(tx); err != nil { + return err + } + if _, err := tx.Exec(llmRecordsSchema); err != nil { + return err + } + if _, err := tx.Exec(llmRecordsMigrate); err != nil { + return err + } + return tx.Commit() } // InsertLLMRecord stores one LLM call record. diff --git a/db/db.go b/db/db.go index de0a738..7bf2c36 100644 --- a/db/db.go +++ b/db/db.go @@ -3,19 +3,84 @@ package db import ( + "context" "database/sql" _ "embed" + "errors" "fmt" "net/url" "strings" + "time" "github.com/Autumn-27/artex/config" + "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" // pgx database/sql driver ("pgx") ) //go:embed schema.sql var schemaSQL string +const schemaMigrationLockKey int64 = 7337741001 + +var schemaDeadlockRetryDelays = [...]time.Duration{ + 100 * time.Millisecond, + 250 * time.Millisecond, + 500 * time.Millisecond, + time.Second, +} + +type schemaExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func isPostgresDeadlock(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "40P01" +} + +func applySchemaWithRetry(ctx context.Context, execer schemaExecer, sleep func(time.Duration)) error { + for attempt := 0; ; attempt++ { + if _, err := execer.ExecContext(ctx, schemaSQL); err != nil { + if !isPostgresDeadlock(err) || attempt >= len(schemaDeadlockRetryDelays) { + return err + } + sleep(schemaDeadlockRetryDelays[attempt]) + continue + } + return nil + } +} + +// withSchemaMigrationLock pins the session-level lock to one checked-out +// connection. Running pg_advisory_lock through *sql.DB is incorrect because a +// later schema or unlock call may use a different pooled PostgreSQL session. +func withSchemaMigrationLock(ctx context.Context, sqlDB *sql.DB, action func(*sql.Conn) error) (err error) { + conn, err := sqlDB.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, schemaMigrationLockKey); err != nil { + return fmt.Errorf("advisory lock: %w", err) + } + defer func() { + if _, unlockErr := conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, schemaMigrationLockKey); unlockErr != nil && err == nil { + err = fmt.Errorf("advisory unlock: %w", unlockErr) + } + }() + return action(conn) +} + +// coordinateWithSchemaMigration makes long, multi-table archive transactions +// mutually exclusive with startup DDL while allowing ordinary runtime queries +// to continue normally. +func coordinateWithSchemaMigration(tx *sql.Tx) error { + if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, schemaMigrationLockKey); err != nil { + return fmt.Errorf("coordinate with schema migration: %w", err) + } + return nil +} + // DSN resolves the PostgreSQL connection string and reports where it came from. // Precedence: env ARTEX_PG_DSN > config file (config.json). There is no // built-in default — it errors if neither source is configured. @@ -72,28 +137,22 @@ func Open(dsn string) (*DB, error) { sqlDB.Close() return nil, fmt.Errorf("ping postgres (%s): %w", config.Redact(dsn), err) } - // Serialize schema migration + seeding across concurrent openers (e.g. parallel - // test packages) using a session-level advisory lock. The lock is released - // automatically when the connection is returned to the pool or closed. - if _, err := sqlDB.Exec(`SELECT pg_advisory_lock(7337741001)`); err != nil { - sqlDB.Close() - return nil, fmt.Errorf("advisory lock: %w", err) - } - // pgx runs multi-statement Exec via the simple protocol when there are no args. - if _, err := sqlDB.Exec(schemaSQL); err != nil { - sqlDB.Exec(`SELECT pg_advisory_unlock(7337741001)`) //nolint:errcheck - sqlDB.Close() - return nil, fmt.Errorf("apply schema: %w", err) - } d := &DB{sqlDB} - if err := d.seedBuiltins(); err != nil { - sqlDB.Exec(`SELECT pg_advisory_unlock(7337741001)`) //nolint:errcheck + // pgx runs multi-statement Exec via the simple protocol when there are no args. + // Keep the dedicated lock connection checked out until both DDL and seeding + // finish so concurrent application instances cannot initialize out of order. + err = withSchemaMigrationLock(context.Background(), sqlDB, func(conn *sql.Conn) error { + if err := applySchemaWithRetry(context.Background(), conn, time.Sleep); err != nil { + return fmt.Errorf("apply schema: %w", err) + } + if err := d.seedBuiltins(); err != nil { + return fmt.Errorf("seed builtins: %w", err) + } + return nil + }) + if err != nil { sqlDB.Close() - return nil, fmt.Errorf("seed builtins: %w", err) - } - if _, err := sqlDB.Exec(`SELECT pg_advisory_unlock(7337741001)`); err != nil { - sqlDB.Close() - return nil, fmt.Errorf("advisory unlock: %w", err) + return nil, err } return d, nil } diff --git a/db/db_test.go b/db/db_test.go index d549cdd..41c5c05 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -1,9 +1,56 @@ package db import ( + "context" + "database/sql" + "errors" "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" ) +type schemaExecFunc func(context.Context, string, ...any) (sql.Result, error) + +func (f schemaExecFunc) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return f(ctx, query, args...) +} + +func TestApplySchemaRetriesOnlyDeadlocks(t *testing.T) { + deadlockAttempts := 0 + delays := []time.Duration{} + err := applySchemaWithRetry(t.Context(), schemaExecFunc(func(context.Context, string, ...any) (sql.Result, error) { + deadlockAttempts++ + if deadlockAttempts < 3 { + return nil, &pgconn.PgError{Code: "40P01", Message: "deadlock detected"} + } + return nil, nil + }), func(delay time.Duration) { + delays = append(delays, delay) + }) + if err != nil { + t.Fatal(err) + } + if deadlockAttempts != 3 { + t.Fatalf("schema attempts=%d, want 3", deadlockAttempts) + } + if len(delays) != 2 || delays[0] != schemaDeadlockRetryDelays[0] || delays[1] != schemaDeadlockRetryDelays[1] { + t.Fatalf("schema retry delays=%v", delays) + } + + nonRetryable := errors.New("permission denied") + nonRetryableAttempts := 0 + err = applySchemaWithRetry(t.Context(), schemaExecFunc(func(context.Context, string, ...any) (sql.Result, error) { + nonRetryableAttempts++ + return nil, nonRetryable + }), func(time.Duration) { + t.Fatal("non-deadlock error must not be retried") + }) + if !errors.Is(err, nonRetryable) || nonRetryableAttempts != 1 { + t.Fatalf("non-retryable result: attempts=%d err=%v", nonRetryableAttempts, err) + } +} + // testDSN returns the configured DSN, skipping the test when neither the env var // nor a config file supplies one (DSN no longer has a built-in default). func testDSN(t *testing.T) string { diff --git a/db/llm_usage.go b/db/llm_usage.go index 21b3b8c..25d3abe 100644 --- a/db/llm_usage.go +++ b/db/llm_usage.go @@ -49,8 +49,18 @@ CREATE INDEX IF NOT EXISTS idx_llm_usage_exp ON llm_usage(exploration_id); // EnsureLLMUsageTable creates the llm_usage metering table if it does not exist. func (d *DB) EnsureLLMUsageTable() error { - _, err := d.Exec(llmUsageSchema) - return err + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck + if err := coordinateWithSchemaMigration(tx); err != nil { + return err + } + if _, err := tx.Exec(llmUsageSchema); err != nil { + return err + } + return tx.Commit() } // InsertLLMUsage appends one metering row. Best-effort: callers log and continue on diff --git a/db/schema.sql b/db/schema.sql index de0b7cd..5c6356d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -448,7 +448,7 @@ CREATE TABLE IF NOT EXISTS task_archives ( progress INTEGER NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), error TEXT NOT NULL DEFAULT '', warnings JSONB NOT NULL DEFAULT '[]', - format_version INTEGER NOT NULL DEFAULT 1, + format_version INTEGER NOT NULL DEFAULT 2, archive_path TEXT NOT NULL DEFAULT '', sha256 TEXT NOT NULL DEFAULT '', original_size BIGINT NOT NULL DEFAULT 0, @@ -468,6 +468,7 @@ CREATE TABLE IF NOT EXISTS task_archives ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE task_archives ALTER COLUMN format_version SET DEFAULT 2; CREATE INDEX IF NOT EXISTS idx_task_archives_state ON task_archives(state, requested_at, id); CREATE INDEX IF NOT EXISTS idx_task_archives_archived ON task_archives(archived_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_task_archives_sources ON task_archives USING GIN(source_task_ids); diff --git a/db/task_archives.go b/db/task_archives.go index bef9f3a..2093975 100644 --- a/db/task_archives.go +++ b/db/task_archives.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/url" "sort" "strconv" @@ -13,7 +14,15 @@ import ( "time" ) -const TaskArchiveFormatVersion = 1 +const ( + TaskArchiveFormatVersion = 2 + TaskArchiveLegacyFormatVersion = 1 + TaskArchiveLLMRecordsPath = "database/llm_records.ndjson" +) + +func IsTaskArchiveFormatSupported(version int) bool { + return version >= TaskArchiveLegacyFormatVersion && version <= TaskArchiveFormatVersion +} const ( ArchiveQueued = "archive_queued" @@ -102,8 +111,8 @@ type TaskArchivePage struct { } // TaskArchiveSnapshot is serialized into manifest.json inside the cold package. -// Each value in Tables is a JSON array generated by PostgreSQL row_to_json, which -// preserves exact IDs, timestamps, arrays and JSON payloads for restoration. +// Small tables remain JSON arrays in Tables. Large v2 tables are streamed to +// package files listed in StreamedTables so their size is not bounded by memory. type TaskArchiveSnapshot struct { FormatVersion int `json:"format_version"` CreatedAt time.Time `json:"created_at"` @@ -114,6 +123,7 @@ type TaskArchiveSnapshot struct { ExclusiveHosts []string `json:"exclusive_hosts"` ExclusiveAssetIDs []int64 `json:"exclusive_asset_ids"` Tables map[string]json.RawMessage `json:"tables"` + StreamedTables map[string]string `json:"streamed_tables,omitempty"` DataCounts map[string]int64 `json:"data_counts"` AggregateStats map[string]any `json:"aggregate_stats"` } @@ -278,12 +288,13 @@ LIMIT 1`, taskID).Scan(&dependent) task_description,task_goal,original_status,category_id_snapshot, category_name_snapshot,source_task_ids,remaining_timeout_seconds,requested_at) VALUES ($1,$2,'queued',0,'','[]',$3,$4,$5,$6,$7,$8,$9,$10,$11,now()) -ON CONFLICT (task_id) DO UPDATE SET - state=CASE WHEN task_archives.state IN ('archive_failed') THEN EXCLUDED.state ELSE task_archives.state END, - phase=CASE WHEN task_archives.state IN ('archive_failed') THEN 'queued' ELSE task_archives.phase END, - progress=CASE WHEN task_archives.state IN ('archive_failed') THEN 0 ELSE task_archives.progress END, - error=CASE WHEN task_archives.state IN ('archive_failed') THEN '' ELSE task_archives.error END, - requested_at=CASE WHEN task_archives.state IN ('archive_failed') THEN now() ELSE task_archives.requested_at END`, + ON CONFLICT (task_id) DO UPDATE SET + state=CASE WHEN task_archives.state IN ('archive_failed') THEN EXCLUDED.state ELSE task_archives.state END, + phase=CASE WHEN task_archives.state IN ('archive_failed') THEN 'queued' ELSE task_archives.phase END, + progress=CASE WHEN task_archives.state IN ('archive_failed') THEN 0 ELSE task_archives.progress END, + error=CASE WHEN task_archives.state IN ('archive_failed') THEN '' ELSE task_archives.error END, + format_version=CASE WHEN task_archives.state IN ('archive_failed') THEN EXCLUDED.format_version ELSE task_archives.format_version END, + requested_at=CASE WHEN task_archives.state IN ('archive_failed') THEN now() ELSE task_archives.requested_at END`, taskID, ArchiveQueued, TaskArchiveFormatVersion, name, description, goal, status, categoryID, categoryName, sources, remaining) if err != nil { @@ -340,15 +351,17 @@ WHERE id=$1 RETURNING `+taskArchiveCols, id, DeleteQueued)) return item, tx.Commit() } -// RecoverTaskArchiveJobs makes active operations retryable after an unclean -// shutdown. Idempotent restore/import semantics keep already-completed pieces safe. +// RecoverTaskArchiveJobs keeps restore/delete resumable after an unclean shutdown. +// An interrupted archive requires an explicit retry: automatic startup retries can +// otherwise form a crash loop when the prior process was killed by resource limits. func (d *DB) RecoverTaskArchiveJobs() error { _, err := d.Exec(`UPDATE task_archives SET - state=CASE state WHEN 'archiving' THEN 'archive_queued' - WHEN 'restoring' THEN 'restore_queued' - WHEN 'deleting' THEN 'delete_queued' ELSE state END, - phase='recovered', error='' -WHERE state IN ('archiving','restoring','deleting')`) + state=CASE state WHEN 'archiving' THEN 'archive_failed' + WHEN 'restoring' THEN 'restore_queued' + WHEN 'deleting' THEN 'delete_queued' ELSE state END, + phase='interrupted', + error=CASE WHEN state='archiving' THEN '上次归档进程异常退出,请手动重试' ELSE '' END + WHERE state IN ('archiving','restoring','deleting')`) return err } @@ -423,11 +436,92 @@ func (d *DB) FailTaskArchiveJob(id int64, activeState string, cause error) error } func queryArchiveRows(q interface { +<<<<<<< Updated upstream QueryRow(query string, args ...any) *sql.Row }, inner string, args ...any) (json.RawMessage, error) { var raw []byte err := q.QueryRow(`SELECT COALESCE(jsonb_agg(to_jsonb(row_data)), '[]'::jsonb) FROM (`+inner+`) row_data`, args...).Scan(&raw) return json.RawMessage(raw), err +======= + Query(query string, args ...any) (*sql.Rows, error) +}, inner string, args ...any) (json.RawMessage, int64, error) { + // Do not aggregate the result in PostgreSQL. A jsonb array has a hard limit + // of 256 MiB for its elements, which large LLM request/response histories can + // exceed even though every individual record is valid. Reading row JSON in + // order also avoids building a second copy of the full table in PostgreSQL. + rows, err := q.Query(`SELECT row_to_json(row_data)::text FROM (`+inner+`) row_data`, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + return encodeArchiveRows(rows) +} + +func encodeArchiveRows(rows interface { + Next() bool + Scan(dest ...any) error + Err() error +}) (json.RawMessage, int64, error) { + var output bytes.Buffer + output.WriteByte('[') + var count int64 + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, 0, err + } + if count > 0 { + output.WriteByte(',') + } + output.Write(raw) + count++ + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + output.WriteByte(']') + return json.RawMessage(output.Bytes()), count, nil +>>>>>>> Stashed changes +} + +func writeArchiveRows(rows interface { + Next() bool + Scan(dest ...any) error + Err() error +}, writer io.Writer) (int64, error) { + var count int64 + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return 0, err + } + if written, err := writer.Write(raw); err != nil { + return 0, err + } else if written != len(raw) { + return 0, io.ErrShortWrite + } + if written, err := io.WriteString(writer, "\n"); err != nil { + return 0, err + } else if written != 1 { + return 0, io.ErrShortWrite + } + count++ + } + if err := rows.Err(); err != nil { + return 0, err + } + return count, nil +} + +func streamArchiveRows(q interface { + Query(query string, args ...any) (*sql.Rows, error) +}, writer io.Writer, inner string, args ...any) (int64, error) { + rows, err := q.Query(`SELECT row_to_json(row_data)::text FROM (`+inner+`) row_data`, args...) + if err != nil { + return 0, err + } + defer rows.Close() + return writeArchiveRows(rows, writer) } func rawRowCount(raw json.RawMessage) int64 { @@ -453,11 +547,28 @@ UNION SELECT value::bigint FROM findings finding // writes are already quiescent at the server barrier; repeatable-read also keeps // the asset and accounting views mutually consistent during serialization. func (d *DB) SnapshotTaskArchive(taskID int64) (*TaskArchiveSnapshot, error) { + return d.snapshotTaskArchive(taskID, nil) +} + +// SnapshotTaskArchiveWithLLMRecords streams the heavyweight record history to +// llmRecords while all other task-owned data is read from the same repeatable +// PostgreSQL snapshot. +func (d *DB) SnapshotTaskArchiveWithLLMRecords(taskID int64, llmRecords io.Writer) (*TaskArchiveSnapshot, error) { + if llmRecords == nil { + return nil, errors.New("nil LLM record archive writer") + } + return d.snapshotTaskArchive(taskID, llmRecords) +} + +func (d *DB) snapshotTaskArchive(taskID int64, llmRecords io.Writer) (*TaskArchiveSnapshot, error) { tx, err := d.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { return nil, err } defer tx.Rollback() //nolint:errcheck + if err := coordinateWithSchemaMigration(tx); err != nil { + return nil, err + } var expID int64 if err := tx.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1 AND deleted_at IS NULL`, taskID).Scan(&expID); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -491,8 +602,23 @@ func (d *DB) SnapshotTaskArchive(taskID int64) (*TaskArchiveSnapshot, error) { {"assets", `SELECT asset.* FROM assets asset WHERE asset.id IN (` + archiveAssetIDsQuery() + `) ORDER BY asset.id`, []any{taskID, expID}}, } counts := make(map[string]int64, len(queries)) + streamedTables := map[string]string{} for _, query := range queries { +<<<<<<< Updated upstream raw, err := queryArchiveRows(tx, query.query, query.args...) +======= + if query.name == "llm_records" && llmRecords != nil { + count, err := streamArchiveRows(tx, llmRecords, query.query, query.args...) + if err != nil { + return nil, fmt.Errorf("snapshot %s: %w", query.name, err) + } + tables[query.name] = json.RawMessage("[]") + counts[query.name] = count + streamedTables[query.name] = TaskArchiveLLMRecordsPath + continue + } + raw, count, err := queryArchiveRows(tx, query.query, query.args...) +>>>>>>> Stashed changes if err != nil { return nil, fmt.Errorf("snapshot %s: %w", query.name, err) } @@ -528,7 +654,8 @@ func (d *DB) SnapshotTaskArchive(taskID int64) (*TaskArchiveSnapshot, error) { snapshot := &TaskArchiveSnapshot{ FormatVersion: TaskArchiveFormatVersion, CreatedAt: time.Now().UTC(), TaskID: taskID, ExplorationID: expID, SourceTaskIDs: sources, Hosts: hosts, ExclusiveHosts: exclusiveHosts, - ExclusiveAssetIDs: exclusiveAssetIDs, Tables: tables, DataCounts: counts, AggregateStats: stats, + ExclusiveAssetIDs: exclusiveAssetIDs, Tables: tables, StreamedTables: streamedTables, + DataCounts: counts, AggregateStats: stats, } return snapshot, tx.Commit() } diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index d3f15b0..71e916a 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "strconv" "strings" "time" @@ -36,6 +37,9 @@ func (d *DB) CompleteTaskArchive( return err } defer tx.Rollback() //nolint:errcheck + if err := coordinateWithSchemaMigration(tx); err != nil { + return err + } var taskID, expID int64 var state string if err := tx.QueryRow(`SELECT archive.task_id,task.exploration_id,archive.state @@ -120,10 +124,11 @@ WHERE id=$1`, taskID); err != nil { return err } if _, err := tx.Exec(`UPDATE task_archives SET - state=$2,phase='ready',progress=100,error='',archive_path=$3,sha256=$4, - original_size=$5,compressed_size=$6,data_counts=$7,aggregate_stats=$8, - archived_at=now(),warnings='[]' -WHERE id=$1`, archiveID, ArchiveReady, archivePath, sha256, originalSize, compressedSize, string(countsRaw), string(statsRaw)); err != nil { + state=$2,phase='ready',progress=100,error='',archive_path=$3,sha256=$4, + original_size=$5,compressed_size=$6,data_counts=$7,aggregate_stats=$8, + format_version=$9,archived_at=now(),warnings='[]' +WHERE id=$1`, archiveID, ArchiveReady, archivePath, sha256, originalSize, compressedSize, + string(countsRaw), string(statsRaw), snapshot.FormatVersion); err != nil { return err } return tx.Commit() @@ -133,14 +138,49 @@ WHERE id=$1`, archiveID, ArchiveReady, archivePath, sha256, originalSize, compre // idempotent for accounting/traffic retry scenarios and returns non-fatal // warnings for global objects that intentionally are not recreated. func (d *DB) RestoreTaskArchive(archiveID int64, snapshot *TaskArchiveSnapshot, remainingTimeoutSeconds int64) ([]string, error) { - if snapshot == nil || snapshot.FormatVersion != TaskArchiveFormatVersion { + return d.restoreTaskArchive(archiveID, snapshot, remainingTimeoutSeconds, nil) +} + +// RestoreTaskArchiveWithLLMRecords restores a v2 package whose heavyweight LLM +// record history is stored as a sequence of JSON objects outside manifest.json. +func (d *DB) RestoreTaskArchiveWithLLMRecords( + archiveID int64, + snapshot *TaskArchiveSnapshot, + remainingTimeoutSeconds int64, + llmRecords io.Reader, +) ([]string, error) { + if llmRecords == nil { + return nil, errors.New("nil streamed LLM record reader") + } + return d.restoreTaskArchive(archiveID, snapshot, remainingTimeoutSeconds, llmRecords) +} + +func (d *DB) restoreTaskArchive( + archiveID int64, + snapshot *TaskArchiveSnapshot, + remainingTimeoutSeconds int64, + llmRecords io.Reader, +) ([]string, error) { + if snapshot == nil || !IsTaskArchiveFormatSupported(snapshot.FormatVersion) { return nil, ErrTaskArchiveFormatMismatch } + streamedLLMRecords := snapshot.StreamedTables["llm_records"] + if (len(snapshot.StreamedTables) > 0 && snapshot.FormatVersion < 2) || + len(snapshot.StreamedTables) > 1 || + (len(snapshot.StreamedTables) == 1 && streamedLLMRecords != TaskArchiveLLMRecordsPath) { + return nil, fmt.Errorf("%w: unsupported streamed table metadata", ErrTaskArchiveFormatMismatch) + } + if streamedLLMRecords != "" && llmRecords == nil { + return nil, fmt.Errorf("%w: streamed LLM records are missing", ErrTaskArchiveFormatMismatch) + } tx, err := d.Begin() if err != nil { return nil, err } defer tx.Rollback() //nolint:errcheck + if err := coordinateWithSchemaMigration(tx); err != nil { + return nil, err + } var taskID, expID int64 var state string if err := tx.QueryRow(`SELECT archive.task_id,task.exploration_id,archive.state @@ -215,7 +255,23 @@ WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID } else { warnings = append(warnings, warning...) } - for _, table := range []string{"task_asset_links", "findings", "llm_records", "llm_usage", "skill_usage", "tool_usage"} { + for _, table := range []string{"task_asset_links", "findings"} { + if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil { + return nil, fmt.Errorf("restore %s: %w", table, err) + } + } + if streamedLLMRecords != "" { + count, err := insertArchiveJSONSequenceRows(tx, "llm_records", llmRecords) + if err != nil { + return nil, fmt.Errorf("restore llm_records: %w", err) + } + if expected := snapshot.DataCounts["llm_records"]; count != expected { + return nil, fmt.Errorf("restore llm_records: row count %d does not match manifest %d", count, expected) + } + } else if err := insertArchiveRows(tx, "llm_records", remappedTables["llm_records"]); err != nil { + return nil, fmt.Errorf("restore llm_records: %w", err) + } + for _, table := range []string{"llm_usage", "skill_usage", "tool_usage"} { if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil { return nil, fmt.Errorf("restore %s: %w", table, err) } @@ -306,6 +362,36 @@ func insertArchiveRows(tx *sql.Tx, table string, raw json.RawMessage) error { return err } +func insertArchiveJSONSequenceRows(tx *sql.Tx, table string, reader io.Reader) (int64, error) { + if table != "llm_records" { + return 0, fmt.Errorf("archive streamed restore table %q is not allowed", table) + } + statement, err := tx.Prepare(`INSERT INTO llm_records SELECT * FROM json_populate_record(NULL::llm_records,$1::json)`) + if err != nil { + return 0, err + } + defer statement.Close() + decoder := json.NewDecoder(reader) + var count int64 + for { + var raw json.RawMessage + if err := decoder.Decode(&raw); errors.Is(err, io.EOF) { + break + } else if err != nil { + return 0, err + } + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return 0, errors.New("streamed archive row must be a JSON object") + } + if _, err := statement.Exec(string(trimmed)); err != nil { + return 0, err + } + count++ + } + return count, nil +} + func restoreArchiveAssets(tx *sql.Tx, taskID int64, raw json.RawMessage) (map[int64]int64, []string, error) { rows, err := decodeArchiveRows(raw) if err != nil { @@ -598,6 +684,9 @@ func (d *DB) DeleteTaskArchiveStub(archiveID int64) error { return err } defer tx.Rollback() //nolint:errcheck + if err := coordinateWithSchemaMigration(tx); err != nil { + return err + } var taskID, expID int64 var state string if err := tx.QueryRow(`SELECT archive.task_id,task.exploration_id,archive.state diff --git a/db/task_archives_test.go b/db/task_archives_test.go index dbe282f..d2496a3 100644 --- a/db/task_archives_test.go +++ b/db/task_archives_test.go @@ -1,13 +1,128 @@ package db import ( + "bytes" "encoding/json" + "errors" "fmt" "strings" "testing" "time" ) +<<<<<<< Updated upstream +======= +type archiveJSONTestRows struct { + values [][]byte + next int +} + +func (r *archiveJSONTestRows) Next() bool { + if r.next >= len(r.values) { + return false + } + r.next++ + return true +} + +func (r *archiveJSONTestRows) Scan(dest ...any) error { + if len(dest) != 1 || r.next == 0 || r.next > len(r.values) { + return fmt.Errorf("invalid archive test row scan") + } + target, ok := dest[0].(*[]byte) + if !ok { + return fmt.Errorf("archive test row destination is %T", dest[0]) + } + *target = append((*target)[:0], r.values[r.next-1]...) + return nil +} + +func (r *archiveJSONTestRows) Err() error { return nil } + +func TestQueryArchiveRowsStreamsJSON(t *testing.T) { + payload := strings.Repeat("large request/response payload ", 64*1024) + values := make([][]byte, 2) + var err error + values[0], err = json.Marshal(map[string]any{"id": int64(1), "body": payload}) + if err != nil { + t.Fatal(err) + } + values[1], err = json.Marshal(map[string]any{"id": int64(2), "body": "quoted: \"value\"\nline"}) + if err != nil { + t.Fatal(err) + } + raw, count, err := encodeArchiveRows(&archiveJSONTestRows{values: values}) + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("row count=%d, want 2", count) + } + var rows []struct { + ID int64 `json:"id"` + Body string `json:"body"` + } + if err := json.Unmarshal(raw, &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("decoded row count=%d, want 2", len(rows)) + } + if rows[0].ID != 1 || rows[0].Body != payload || rows[1].ID != 2 || rows[1].Body != "quoted: \"value\"\nline" { + t.Fatal("streamed rows were reordered or truncated") + } +} + +func TestWriteArchiveRowsProducesJSONSequence(t *testing.T) { + values := [][]byte{ + json.RawMessage(`{"id":1,"body":"first"}`), + json.RawMessage(`{"id":2,"body":"second\\nline"}`), + } + var output bytes.Buffer + count, err := writeArchiveRows(&archiveJSONTestRows{values: values}, &output) + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("streamed row count=%d, want 2", count) + } + decoder := json.NewDecoder(&output) + for wantID := int64(1); wantID <= 2; wantID++ { + var row struct { + ID int64 `json:"id"` + } + if err := decoder.Decode(&row); err != nil { + t.Fatal(err) + } + if row.ID != wantID { + t.Fatalf("streamed row id=%d, want %d", row.ID, wantID) + } + } +} + +func TestTaskArchiveFormatCompatibility(t *testing.T) { + for _, version := range []int{TaskArchiveLegacyFormatVersion, TaskArchiveFormatVersion} { + if !IsTaskArchiveFormatSupported(version) { + t.Fatalf("archive format %d should be supported", version) + } + } + for _, version := range []int{0, TaskArchiveFormatVersion + 1} { + if IsTaskArchiveFormatSupported(version) { + t.Fatalf("archive format %d should be rejected", version) + } + } + invalidSnapshots := []*TaskArchiveSnapshot{ + {FormatVersion: TaskArchiveLegacyFormatVersion, StreamedTables: map[string]string{"llm_records": TaskArchiveLLMRecordsPath}}, + {FormatVersion: TaskArchiveFormatVersion, StreamedTables: map[string]string{"unknown": "database/unknown.ndjson"}}, + } + for _, snapshot := range invalidSnapshots { + if _, err := (&DB{}).RestoreTaskArchive(1, snapshot, 0); !errors.Is(err, ErrTaskArchiveFormatMismatch) { + t.Fatalf("invalid streamed table metadata returned %v", err) + } + } +} + +>>>>>>> Stashed changes func TestTaskArchiveDatabaseRoundTrip(t *testing.T) { d, err := Open(testDSN(t)) if err != nil { @@ -79,6 +194,12 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if err := d.InsertToolUsage(&ToolUsage{ToolKey: toolName, AgentKey: "worker", TaskID: task.ID, ExplorationID: task.ExplorationID}); err != nil { t.Fatal(err) } + if err := d.InsertLLMRecord(&LLMRecord{ + TaskID: fmt.Sprint(task.ID), Model: "archive-model", SessionID: "archive-session", + Status: "ok", RawRequest: strings.Repeat("request", 1024), RawResponse: strings.Repeat("response", 1024), + }); err != nil { + t.Fatal(err) + } if _, err := d.AddFinding(task.ID, nodeID, vulnclass, "archive finding", SeverityCritical, "summary", "evidence", "worker", []int64{assetID}); err != nil { t.Fatal(err) } @@ -90,10 +211,17 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if err != nil || claimed == nil || claimed.ID != archive.ID || claimed.State != Archiving { t.Fatalf("claim = %+v, %v", claimed, err) } - snapshot, err := d.SnapshotTaskArchive(task.ID) + var llmRecords bytes.Buffer + snapshot, err := d.SnapshotTaskArchiveWithLLMRecords(task.ID, &llmRecords) if err != nil { t.Fatal(err) } + if snapshot.StreamedTables["llm_records"] != TaskArchiveLLMRecordsPath || snapshot.DataCounts["llm_records"] != 1 { + t.Fatalf("unexpected streamed LLM metadata: paths=%v counts=%v", snapshot.StreamedTables, snapshot.DataCounts) + } + if rawRowCount(snapshot.Tables["llm_records"]) != 0 { + t.Fatal("streamed LLM records were also retained in manifest memory") + } if snapshot.DataCounts["assets"] != 1 || snapshot.DataCounts["exploration_nodes"] < 2 { t.Fatalf("unexpected snapshot counts: %#v", snapshot.DataCounts) } @@ -138,7 +266,9 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if err != nil || claimed == nil || claimed.State != Restoring { t.Fatalf("restore claim = %+v, %v", claimed, err) } - warnings, err := d.RestoreTaskArchive(archive.ID, snapshot, int64((10 * time.Minute).Seconds())) + warnings, err := d.RestoreTaskArchiveWithLLMRecords( + archive.ID, snapshot, int64((10 * time.Minute).Seconds()), bytes.NewReader(llmRecords.Bytes()), + ) if err != nil { t.Fatal(err) } @@ -170,6 +300,14 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if nodes < 2 || assets != 1 || usage != 1 { t.Fatalf("restore incomplete nodes=%d assets=%d usage=%d", nodes, assets, usage) } + var restoredRawRequest, restoredRawResponse string + if err := d.QueryRow(`SELECT COALESCE(raw_request,''),COALESCE(raw_response,'') FROM llm_records WHERE task_id=$1`, fmt.Sprint(task.ID)). + Scan(&restoredRawRequest, &restoredRawResponse); err != nil { + t.Fatal(err) + } + if restoredRawRequest != strings.Repeat("request", 1024) || restoredRawResponse != strings.Repeat("response", 1024) { + t.Fatal("restored streamed LLM record body was truncated") + } var restoredCompanyID *int64 var restoredStatus, restoredError string var restoredExhaustedAt, restoredCreatedAt time.Time @@ -242,6 +380,42 @@ func TestTaskArchiveBlockersIgnoreQueuedDependents(t *testing.T) { } } +func TestRecoverInterruptedArchiveRequiresManualRetry(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + task, err := d.CreateTaskWithOptions("interrupted archive", "must not restart automatically", TaskCreateOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { + _, _ = d.Exec(`DELETE FROM task_archives WHERE task_id=$1`, task.ID) + _ = d.DeleteTask(task.ID) + }() + if err := d.SetPaused(task.ID, true); err != nil { + t.Fatal(err) + } + queued, err := d.QueueTaskArchive(task.ID) + if err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE task_archives SET state=$2,phase='snapshot_database',progress=10 WHERE id=$1`, queued.ID, Archiving); err != nil { + t.Fatal(err) + } + if err := d.RecoverTaskArchiveJobs(); err != nil { + t.Fatal(err) + } + recovered, err := d.GetTaskArchive(queued.ID) + if err != nil { + t.Fatal(err) + } + if recovered == nil || recovered.State != ArchiveFailed || recovered.Phase != "interrupted" || recovered.Error == "" { + t.Fatalf("recovered archive=%+v, want explicit-retry failure", recovered) + } +} + func assertArchiveGlobalStats(t *testing.T, d *DB, profileName, skillName, toolName, vulnclass string) { t.Helper() profiles, err := d.UsageByProfile() diff --git a/server/task_archive_package.go b/server/task_archive_package.go index ce4494d..bc55617 100644 --- a/server/task_archive_package.go +++ b/server/task_archive_package.go @@ -480,11 +480,20 @@ func writeTaskArchivePackage(path, payloadDir string, snapshot *pgdb.TaskArchive if err = os.MkdirAll(filepath.Dir(path), archiveDirMode); err != nil { return 0, 0, "", err } - manifest, err := json.Marshal(snapshot) + manifestFile, err := os.OpenFile( + filepath.Join(payloadDir, "manifest.json"), + os.O_CREATE|os.O_TRUNC|os.O_WRONLY, + archiveFileMode, + ) if err != nil { return 0, 0, "", err } - if err = os.WriteFile(filepath.Join(payloadDir, "manifest.json"), manifest, archiveFileMode); err != nil { + encodeErr := json.NewEncoder(manifestFile).Encode(snapshot) + var syncErr error + if encodeErr == nil { + syncErr = manifestFile.Sync() + } + if err := errors.Join(encodeErr, syncErr, manifestFile.Close()); err != nil { return 0, 0, "", err } temporary := path + ".partial" diff --git a/server/task_archive_package_test.go b/server/task_archive_package_test.go index 74e86ad..9846673 100644 --- a/server/task_archive_package_test.go +++ b/server/task_archive_package_test.go @@ -47,7 +47,18 @@ func TestTaskArchivePackageFilesRoundTrip(t *testing.T) { } archivePath := taskArchivePath(dataDir, 1, taskID) - snapshot := &pgdb.TaskArchiveSnapshot{FormatVersion: pgdb.TaskArchiveFormatVersion, TaskID: 42, ExplorationID: explorationID} + streamPath := filepath.Join(stage.payload, filepath.FromSlash(pgdb.TaskArchiveLLMRecordsPath)) + if err := os.MkdirAll(filepath.Dir(streamPath), archiveDirMode); err != nil { + t.Fatal(err) + } + streamBody := []byte("{\"id\":1,\"raw_request\":\"large\"}\n") + if err := os.WriteFile(streamPath, streamBody, archiveFileMode); err != nil { + t.Fatal(err) + } + snapshot := &pgdb.TaskArchiveSnapshot{ + FormatVersion: pgdb.TaskArchiveFormatVersion, TaskID: 42, ExplorationID: explorationID, + StreamedTables: map[string]string{"llm_records": pgdb.TaskArchiveLLMRecordsPath}, + } original, compressed, checksum, err := writeTaskArchivePackage(archivePath, stage.payload, snapshot) if err != nil { t.Fatal(err) @@ -62,6 +73,9 @@ func TestTaskArchivePackageFilesRoundTrip(t *testing.T) { if err := extractTaskArchivePackage(archivePath, checksum, extracted); err != nil { t.Fatal(err) } + if got, err := os.ReadFile(filepath.Join(extracted, filepath.FromSlash(pgdb.TaskArchiveLLMRecordsPath))); err != nil || !bytes.Equal(got, streamBody) { + t.Fatalf("streamed LLM archive payload=%q err=%v", got, err) + } installed, err := installTaskArchiveFiles(dataDir, extracted, taskID, 1) if err != nil { t.Fatal(err) diff --git a/server/task_archives.go b/server/task_archives.go index c953a1a..30cea69 100644 --- a/server/task_archives.go +++ b/server/task_archives.go @@ -131,13 +131,15 @@ func (s *Server) archiveTask(job *pgdb.TaskArchive) (runErr error) { if err := s.waitTaskQuiescent(drainCtx, taskID); err != nil { return errors.New("任务仍有运行中的 Agent,请先暂停后重试归档") } - _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "snapshot_database", 10) - snapshot, err := s.m.pg.SnapshotTaskArchive(job.TaskID) + task, err := s.m.pg.GetTask(job.TaskID) if err != nil { return err } - _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "stage_files", 25) - fileStage, err := stageTaskArchiveFiles(s.m.dir, job.ID, taskID, snapshot.ExplorationID) + if task == nil { + return pgdb.ErrTaskArchiveNotFound + } + _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "stage_files", 10) + fileStage, err := stageTaskArchiveFiles(s.m.dir, job.ID, taskID, task.ExplorationID) if err != nil { return err } @@ -146,6 +148,27 @@ func (s *Server) archiveTask(job *pgdb.TaskArchive) (runErr error) { runErr = errors.Join(runErr, fileStage.rollback()) } }() + streamPath := filepath.Join(fileStage.payload, filepath.FromSlash(pgdb.TaskArchiveLLMRecordsPath)) + if err := os.MkdirAll(filepath.Dir(streamPath), archiveDirMode); err != nil { + return err + } + streamFile, err := os.OpenFile(streamPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode) + if err != nil { + return err + } + _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "snapshot_database", 20) + snapshot, snapshotErr := s.m.pg.SnapshotTaskArchiveWithLLMRecords(job.TaskID, streamFile) + var syncErr error + if snapshotErr == nil { + syncErr = streamFile.Sync() + } + closeErr := streamFile.Close() + if err := errors.Join(snapshotErr, syncErr, closeErr); err != nil { + return err + } + if snapshot.ExplorationID != task.ExplorationID { + return errors.New("任务探索记录在归档快照期间发生变化") + } if s.m.traffic != nil && len(snapshot.Hosts) > 0 { _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "snapshot_traffic", 38) trafficCount, err := s.m.traffic.ExportHosts(snapshot.Hosts, filepath.Join(fileStage.payload, "traffic")) @@ -244,9 +267,27 @@ func (s *Server) restoreTaskArchive(job *pgdb.TaskArchive) (runErr error) { if err := json.Unmarshal(manifestRaw, &snapshot); err != nil { return err } - if snapshot.TaskID != job.TaskID || snapshot.FormatVersion != pgdb.TaskArchiveFormatVersion { + if snapshot.TaskID != job.TaskID || !pgdb.IsTaskArchiveFormatSupported(snapshot.FormatVersion) { return pgdb.ErrTaskArchiveFormatMismatch } + var llmRecordsFile *os.File + relative, hasStreamedLLMRecords := snapshot.StreamedTables["llm_records"] + if len(snapshot.StreamedTables) > 0 && + (snapshot.FormatVersion < 2 || !hasStreamedLLMRecords || len(snapshot.StreamedTables) != 1 || + relative != pgdb.TaskArchiveLLMRecordsPath) { + return pgdb.ErrTaskArchiveFormatMismatch + } + if hasStreamedLLMRecords { + llmRecordsFile, err = os.Open(filepath.Join(extracted, filepath.FromSlash(relative))) + if err != nil { + return err + } + defer func() { + if llmRecordsFile != nil { + _ = llmRecordsFile.Close() + } + }() + } if s.m.traffic != nil { _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "restore_traffic", 35) if _, err := s.m.traffic.ImportArchive(filepath.Join(extracted, "traffic")); err != nil { @@ -265,7 +306,17 @@ func (s *Server) restoreTaskArchive(job *pgdb.TaskArchive) (runErr error) { } }() _ = s.m.pg.UpdateTaskArchiveProgress(job.ID, "restore_database", 70) - warnings, err := s.m.pg.RestoreTaskArchive(job.ID, &snapshot, job.RemainingTimeoutSeconds) + var warnings []string + if llmRecordsFile != nil { + warnings, err = s.m.pg.RestoreTaskArchiveWithLLMRecords( + job.ID, &snapshot, job.RemainingTimeoutSeconds, llmRecordsFile, + ) + closeErr := llmRecordsFile.Close() + llmRecordsFile = nil + err = errors.Join(err, closeErr) + } else { + warnings, err = s.m.pg.RestoreTaskArchive(job.ID, &snapshot, job.RemainingTimeoutSeconds) + } if err != nil { return err } From 4157fa248badff2fcdabbc404dfbca5c7466011b Mon Sep 17 00:00:00 2001 From: pphua Date: Sat, 29 Aug 2026 21:35:15 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E6=96=B0=E5=A2=9Eworker=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/cancelcause.go | 2 + agent/planner.go | 20 +- agent/tools.go | 40 ++ agent/worker.go | 66 ++- agent/worker_intervention_test.go | 28 + db/exploration.go | 473 ++++++++++++++++- db/intent_intervention_lifecycle_test.go | 204 +++++++ server/engine.go | 501 +++++++++++++++++- server/engine_cancelcause_test.go | 1 + server/goals.go | 7 + server/intent_intervention.go | 125 +++++ server/intent_intervention_protocol_test.go | 378 +++++++++++++ server/manager.go | 39 +- server/server.go | 9 + .../tasks/detail/_tabs/sessions-tab.tsx | 177 +++++-- web/src/lib/api.ts | 8 + web/src/lib/mock/handler.ts | 96 +++- 17 files changed, 2095 insertions(+), 79 deletions(-) create mode 100644 agent/worker_intervention_test.go create mode 100644 db/intent_intervention_lifecycle_test.go create mode 100644 server/intent_intervention.go create mode 100644 server/intent_intervention_protocol_test.go diff --git a/agent/cancelcause.go b/agent/cancelcause.go index f4705bc..e61cfaf 100644 --- a/agent/cancelcause.go +++ b/agent/cancelcause.go @@ -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", diff --git a/agent/planner.go b/agent/planner.go index dfacc91..21437cf 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -116,6 +116,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 = 被删目标文本). @@ -123,14 +124,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; @@ -155,6 +159,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" @@ -240,6 +247,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 列全部事实、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 驱动,你无需、也没有别的“一键完成”手段。 diff --git a/agent/tools.go b/agent/tools.go index f8493ab..029f973 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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)) diff --git a/agent/worker.go b/agent/worker.go index f2e5ce8..34f9188 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/Autumn-27/artex/db" @@ -64,6 +65,40 @@ type Worker struct { injectConstraints func() bool } +<<<<<<< Updated upstream +======= +// 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 = "" +} + +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. +func (w *Worker) SetNonStreaming(fn func() bool) { w.nonStreamingFn = fn } + +func (w *Worker) nonStreaming() bool { return w.nonStreamingFn != nil && w.nonStreamingFn() } + +>>>>>>> Stashed changes // SetConstraintInject wires a resolver deciding whether this task's operation // constraints get injected into the worker system prompt. nil = inject (default). func (w *Worker) SetConstraintInject(fn func() bool) { w.injectConstraints = fn } @@ -263,6 +298,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) @@ -346,7 +393,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) { @@ -386,11 +433,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请优先执行这条人工输入。" } } diff --git a/agent/worker_intervention_test.go b/agent/worker_intervention_test.go new file mode 100644 index 0000000..ab8977e --- /dev/null +++ b/agent/worker_intervention_test.go @@ -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") + } +} diff --git a/db/exploration.go b/db/exploration.go index f62e280..1913198 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -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. @@ -972,7 +1408,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 } @@ -1023,7 +1460,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 @@ -1154,10 +1591,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 @@ -1194,7 +1631,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 @@ -1257,7 +1694,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 { @@ -1302,7 +1739,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 { @@ -1338,7 +1775,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 } @@ -1348,7 +1785,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 } @@ -1381,7 +1818,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 @@ -1401,7 +1838,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 @@ -1423,11 +1860,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 { @@ -1449,7 +1886,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 { @@ -1471,7 +1908,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 { @@ -1542,7 +1979,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 @@ -1566,7 +2003,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 } @@ -1600,7 +2037,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 { diff --git a/db/intent_intervention_lifecycle_test.go b/db/intent_intervention_lifecycle_test.go new file mode 100644 index 0000000..c27a746 --- /dev/null +++ b/db/intent_intervention_lifecycle_test.go @@ -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) + } +} diff --git a/server/engine.go b/server/engine.go index f3c7074..2754c09 100644 --- a/server/engine.go +++ b/server/engine.go @@ -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 { diff --git a/server/engine_cancelcause_test.go b/server/engine_cancelcause_test.go index d1f4f7a..63c7324 100644 --- a/server/engine_cancelcause_test.go +++ b/server/engine_cancelcause_test.go @@ -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) diff --git a/server/goals.go b/server/goals.go index dbc497d..a4e8a9b 100644 --- a/server/goals.go +++ b/server/goals.go @@ -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 diff --git a/server/intent_intervention.go b/server/intent_intervention.go new file mode 100644 index 0000000..6df99fa --- /dev/null +++ b/server/intent_intervention.go @@ -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, + }) +} diff --git a/server/intent_intervention_protocol_test.go b/server/intent_intervention_protocol_test.go new file mode 100644 index 0000000..8347eca --- /dev/null +++ b/server/intent_intervention_protocol_test.go @@ -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()) + } +} diff --git a/server/manager.go b/server/manager.go index cb198c2..d5c20e8 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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 diff --git a/server/server.go b/server/server.go index 942c8ca..fe798b8 100644 --- a/server/server.go +++ b/server/server.go @@ -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) } @@ -458,6 +461,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 { @@ -734,6 +742,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) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index bf8eea9..add01d1 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -13,7 +13,6 @@ import { Loader2Icon, PaperclipIcon, PauseIcon, - PlayIcon, RadioIcon, RotateCwIcon, ShieldAlertIcon, @@ -42,6 +41,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"; @@ -92,6 +92,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 @@ -312,8 +324,6 @@ function SessionItem({ hasPending, unread, onClick, - onPause, - onResume, onCancel, controlling, deleted, @@ -324,8 +334,6 @@ function SessionItem({ hasPending?: boolean; unread?: number; onClick: () => void; - onPause?: () => void; - onResume?: () => void; onCancel?: () => void; controlling?: boolean; deleted?: boolean; @@ -338,7 +346,8 @@ function SessionItem({ ) : 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 (
)} - {controllable && ( + {cancellable && (
- {s.status === "running" ? ( - - ) : ( - - )}
+ ) : active.role === "worker" && + !active.inherited && + (active.status === "running" || active.status === "paused" || active.status === "pending") ? ( +
+ + { + 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" + /> + + MAX_WORKER_MESSAGE_CHARS && "text-destructive", + )} + > + {workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS} + + {active.status === "running" ? ( + void controlWorker(active, "pause")} + disabled={controllingIntent === active.intent_id} + title="暂停当前 Worker" + aria-label="暂停当前 Worker" + > + {controllingIntent === active.intent_id ? ( + + ) : ( + + )} + + ) : ( + MAX_WORKER_MESSAGE_CHARS + } + title="发送消息" + aria-label="发送消息" + > + {workerMessageSending ? : } + + )} + + +
) : (
(`/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(`/tasks/${id}/llm/resolution`), // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 rerunIntent: (taskId: string, intentId: string) => diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 6002dae..dd61d86 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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(); +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; From cb706d3c8c88bf144a597830a93d395a2fc73152 Mon Sep 17 00:00:00 2001 From: pphua Date: Sat, 29 Aug 2026 21:35:15 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=96=B0=E5=A2=9Eworker=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/cancelcause.go | 2 + agent/planner.go | 20 +- agent/tools.go | 40 ++ agent/worker.go | 56 +- agent/worker_intervention_test.go | 28 + db/exploration.go | 473 ++++++++++++++++- db/intent_intervention_lifecycle_test.go | 204 +++++++ server/engine.go | 501 +++++++++++++++++- server/engine_cancelcause_test.go | 1 + server/goals.go | 7 + server/intent_intervention.go | 125 +++++ server/intent_intervention_protocol_test.go | 378 +++++++++++++ server/manager.go | 39 +- server/server.go | 9 + .../tasks/detail/_tabs/sessions-tab.tsx | 173 ++++-- web/src/lib/api.ts | 8 + web/src/lib/mock/handler.ts | 96 +++- 17 files changed, 2082 insertions(+), 78 deletions(-) create mode 100644 agent/worker_intervention_test.go create mode 100644 db/intent_intervention_lifecycle_test.go create mode 100644 server/intent_intervention.go create mode 100644 server/intent_intervention_protocol_test.go diff --git a/agent/cancelcause.go b/agent/cancelcause.go index f4705bc..e61cfaf 100644 --- a/agent/cancelcause.go +++ b/agent/cancelcause.go @@ -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", diff --git a/agent/planner.go b/agent/planner.go index 95802f8..3f63166 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -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 驱动,你无需、也没有别的“一键完成”手段。 diff --git a/agent/tools.go b/agent/tools.go index ebed6d8..3a34ca1 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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)) diff --git a/agent/worker.go b/agent/worker.go index 2c77722..84f693b 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -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 = "" +} + +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请优先执行这条人工输入。" } } diff --git a/agent/worker_intervention_test.go b/agent/worker_intervention_test.go new file mode 100644 index 0000000..ab8977e --- /dev/null +++ b/agent/worker_intervention_test.go @@ -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") + } +} diff --git a/db/exploration.go b/db/exploration.go index eb0e677..7c40e85 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -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 { diff --git a/db/intent_intervention_lifecycle_test.go b/db/intent_intervention_lifecycle_test.go new file mode 100644 index 0000000..c27a746 --- /dev/null +++ b/db/intent_intervention_lifecycle_test.go @@ -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) + } +} diff --git a/server/engine.go b/server/engine.go index f3c7074..2754c09 100644 --- a/server/engine.go +++ b/server/engine.go @@ -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 { diff --git a/server/engine_cancelcause_test.go b/server/engine_cancelcause_test.go index d1f4f7a..63c7324 100644 --- a/server/engine_cancelcause_test.go +++ b/server/engine_cancelcause_test.go @@ -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) diff --git a/server/goals.go b/server/goals.go index c8e6d98..05f2c7e 100644 --- a/server/goals.go +++ b/server/goals.go @@ -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 diff --git a/server/intent_intervention.go b/server/intent_intervention.go new file mode 100644 index 0000000..6df99fa --- /dev/null +++ b/server/intent_intervention.go @@ -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, + }) +} diff --git a/server/intent_intervention_protocol_test.go b/server/intent_intervention_protocol_test.go new file mode 100644 index 0000000..8347eca --- /dev/null +++ b/server/intent_intervention_protocol_test.go @@ -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()) + } +} diff --git a/server/manager.go b/server/manager.go index cb198c2..d5c20e8 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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 diff --git a/server/server.go b/server/server.go index f9ed5f4..b6a4182 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 937b97e..46ed163 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -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({ ) : 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 (
)} - {controllable && ( + {cancellable && (
- {s.status === "running" ? ( - - ) : ( - - )}
+ ) : active.role === "worker" && + !active.inherited && + (active.status === "running" || active.status === "paused" || active.status === "pending") ? ( +
+ + { + 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" + /> + + MAX_WORKER_MESSAGE_CHARS && "text-destructive", + )} + > + {workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS} + + {active.status === "running" ? ( + void controlWorker(active, "pause")} + disabled={controllingIntent === active.intent_id} + title="暂停当前 Worker" + aria-label="暂停当前 Worker" + > + {controllingIntent === active.intent_id ? ( + + ) : ( + + )} + + ) : ( + MAX_WORKER_MESSAGE_CHARS + } + title="发送消息" + aria-label="发送消息" + > + {workerMessageSending ? : } + + )} + + +
) : (
(`/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(`/tasks/${id}/llm/resolution`), // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 rerunIntent: (taskId: string, intentId: string) => diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 407fe4e..98b6c44 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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(); +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; From 7e633b2056745f297856900850639609579217b1 Mon Sep 17 00:00:00 2001 From: pphua Date: Sat, 29 Aug 2026 21:44:39 +0800 Subject: [PATCH 4/6] fix: sync worker directions across agents --- agent/mainagent.go | 24 ++- agent/worker_direction_sync_test.go | 155 ++++++++++++++++++++ db/exploration.go | 12 +- db/intent_intervention_lifecycle_test.go | 69 +++++++++ server/intent_intervention_protocol_test.go | 32 ++++ 5 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 agent/worker_direction_sync_test.go diff --git a/agent/mainagent.go b/agent/mainagent.go index 9c40eca..2962252 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "fmt" "github.com/Autumn-27/artex/db" @@ -85,6 +86,27 @@ func mainAgentSystem(goal, dataDir, workDir string) string { return body + artifactSpec(workDir) } +// mainAgentInput keeps Worker-side human steering visible to the Main Agent on +// every fresh Chat session. The direction records remain separate from the +// current user turn so the model does not mistake persisted context for a new +// instruction that must be sent to the Worker again. +func mainAgentInput(ts *db.ExplorationStore, message string) string { + if ts == nil { + return message + } + directions, err := ts.LatestWorkerDirections(workerDirectionOverviewLimit) + if err != nil || len(directions) == 0 { + return message + } + raw, err := json.Marshal(compactWorkerDirections(directions)) + if err != nil { + return message + } + return "【系统同步的 Worker 最新人工方向】\n" + + "以下内容来自持久化的 Worker 对话。回答、观察和调度时必须纳入,但不要把它当作本轮新消息重复下发。\n" + + string(raw) + "\n\n【用户本轮消息】\n" + message +} + // Chat handles one human message and returns the assistant reply. emit, if // non-nil, receives each execution step (thinking / tool_use / tool_result / // text / result) so the main-agent session shows its work — exactly like the @@ -153,7 +175,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t // C2: this session is fresh each turn; re-unlock skill-gated MCPs from prior // Skill() calls in the reloaded history so revealed tools stay callable. seedUnlockFromHistory(s.Messages(), def.UnlockSkill) - text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) { + text, _, err := captureRunSession(ctx, s, mainAgentInput(ts, message), func(r db.Activity) { if emit != nil { r.Worker = "mainagent" emit(r) diff --git a/agent/worker_direction_sync_test.go b/agent/worker_direction_sync_test.go new file mode 100644 index 0000000..a1cf7f8 --- /dev/null +++ b/agent/worker_direction_sync_test.go @@ -0,0 +1,155 @@ +package agent + +import ( + "context" + "iter" + "strings" + "sync" + "testing" + + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/norma/llm" +) + +type directionCaptureProvider struct { + mu sync.Mutex + requests []llm.CompletionRequest +} + +func (p *directionCaptureProvider) Stream(_ context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + p.mu.Lock() + p.requests = append(p.requests, req) + p.mu.Unlock() + return func(yield func(llm.StreamEvent, error) bool) { + if !yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "同步完成"}, nil) { + return + } + if !yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil) { + return + } + yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil) + } +} + +func (p *directionCaptureProvider) Complete(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { + p.mu.Lock() + p.requests = append(p.requests, req) + p.mu.Unlock() + return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("同步完成")}}, "end_turn", llm.Usage{}, nil +} + +func (p *directionCaptureProvider) lastRequest(t *testing.T) llm.CompletionRequest { + t.Helper() + p.mu.Lock() + defer p.mu.Unlock() + if len(p.requests) == 0 { + t.Fatal("provider received no completion request") + } + return p.requests[len(p.requests)-1] +} + +func lastUserText(t *testing.T, req llm.CompletionRequest) string { + t.Helper() + for i := len(req.Messages) - 1; i >= 0; i-- { + if req.Messages[i].Role == llm.RoleUser { + return req.Messages[i].Text() + } + } + t.Fatal("completion request has no user message") + return "" +} + +type directionAgentFixture struct { + d *db.DB + task *db.Task + store *db.ExplorationStore + intentID int64 + activity db.Activity +} + +func newDirectionAgentFixture(t *testing.T) *directionAgentFixture { + t.Helper() + d := testDB(t) + task, err := d.CreateTask("agent direction sync", "synchronize worker steering", nil, 0, 0) + if err != nil { + d.Close() + t.Fatal(err) + } + t.Cleanup(func() { + _ = d.DeleteTask(task.ID) + _ = d.Close() + }) + store := d.Exploration(task.ExplorationID) + intentID, err := store.AddIntent(map[string]any{"summary": "原始接口枚举方向"}, 1, nil, "planner") + if err != nil { + t.Fatal(err) + } + if claimed, err := store.ClaimIntent(intentID, "work#direction"); err != nil || !claimed { + t.Fatalf("claim direction fixture: claimed=%v err=%v", claimed, err) + } + const requestID = "agent-direction-sync-1" + const direction = "停止接口枚举,立即聚焦登录后的水平越权" + if _, err := store.ReserveIntentIntervention(intentID, requestID, direction); err != nil { + t.Fatal(err) + } + if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed { + t.Fatalf("pause direction fixture: changed=%v err=%v", changed, err) + } + activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, direction) + if err != nil || duplicate { + t.Fatalf("accept direction fixture: activity=%+v duplicate=%v err=%v", activity, duplicate, err) + } + return &directionAgentFixture{d: d, task: task, store: store, intentID: intentID, activity: activity} +} + +func TestGraphOverviewIncludesLatestWorkerDirection(t *testing.T) { + f := newDirectionAgentFixture(t) + overview := NewToolSet(f.store, "planner").graphOverviewData() + directions, ok := overview["worker_directions"].([]map[string]any) + if !ok || len(directions) != 1 { + t.Fatalf("worker_directions=%T %+v", overview["worker_directions"], overview["worker_directions"]) + } + if directions[0]["activity_id"] != f.activity.ID || directions[0]["intent_id"] != f.intentID || + directions[0]["intent_summary"] != "原始接口枚举方向" || + directions[0]["message"] != "停止接口枚举,立即聚焦登录后的水平越权" { + t.Fatalf("worker direction overview=%+v", directions[0]) + } +} + +func TestPlannerRequestIncludesWorkerMessageTriggerAndPersistedDirection(t *testing.T) { + f := newDirectionAgentFixture(t) + provider := &directionCaptureProvider{} + planner := NewPlanner(provider, "test-model", t.TempDir(), nil, 0, 1) + triggerDetail := "本轮只验证登录后的对象级授权" + _, _, err := planner.Plan(context.Background(), f.task.ID, nil, f.store, f.task.Goal, []TriggerEvent{{ + Kind: TriggerWorkerMessage, ActivityID: f.activity.ID, IntentID: f.intentID, Detail: triggerDetail, + }}, nil) + if err != nil { + t.Fatal(err) + } + input := lastUserText(t, provider.lastRequest(t)) + for _, want := range []string{"【本次触发本轮的实际变动】", triggerDetail, `"worker_directions"`, "停止接口枚举,立即聚焦登录后的水平越权"} { + if !strings.Contains(input, want) { + t.Fatalf("Planner input missing %q:\n%s", want, input) + } + } +} + +func TestMainAgentNextTurnIncludesPersistedWorkerDirection(t *testing.T) { + f := newDirectionAgentFixture(t) + provider := &directionCaptureProvider{} + mainAgent := NewMainAgent(provider, "test-model", t.TempDir(), nil, 0, 1) + const userMessage = "汇报当前执行方向" + if _, err := mainAgent.Chat(context.Background(), f.task.ID, nil, f.store, f.task.Goal, userMessage, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + input := lastUserText(t, provider.lastRequest(t)) + for _, want := range []string{"【系统同步的 Worker 最新人工方向】", "停止接口枚举,立即聚焦登录后的水平越权", "【用户本轮消息】", userMessage} { + if !strings.Contains(input, want) { + t.Fatalf("Main Agent input missing %q:\n%s", want, input) + } + } + if strings.Index(input, "【系统同步的 Worker 最新人工方向】") > strings.Index(input, "【用户本轮消息】") { + t.Fatalf("Main Agent context is not separated before current user message:\n%s", input) + } +} diff --git a/db/exploration.go b/db/exploration.go index 7c40e85..6a05f9a 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -360,13 +360,13 @@ type IntentIntervention struct { // 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 + ActivityID int64 + IntentID int64 + RequestID string + Message string + IntentState string IntentSummary string - CreatedAt time.Time + CreatedAt time.Time } // lockIntentIntervention serializes request-id reservation/finalization across diff --git a/db/intent_intervention_lifecycle_test.go b/db/intent_intervention_lifecycle_test.go index c27a746..9363e7c 100644 --- a/db/intent_intervention_lifecycle_test.go +++ b/db/intent_intervention_lifecycle_test.go @@ -202,3 +202,72 @@ func TestIntentInterventionAcceptanceCanRetryAfterStateSettlement(t *testing.T) t.Fatalf("retry acceptance: activity=%+v duplicate=%v err=%v", activity, duplicate, err) } } + +func acceptTestWorkerDirection(t *testing.T, store *ExplorationStore, intentID int64, requestID, message string) Activity { + t.Helper() + if claimed, err := store.ClaimIntent(intentID, "direction-test-worker"); err != nil || !claimed { + t.Fatalf("claim direction intent: claimed=%v err=%v", claimed, err) + } + if _, err := store.ReserveIntentIntervention(intentID, requestID, message); err != nil { + t.Fatal(err) + } + if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed { + t.Fatalf("pause direction intent: changed=%v err=%v", changed, err) + } + activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, message) + if err != nil || duplicate { + t.Fatalf("accept direction: activity=%+v duplicate=%v err=%v", activity, duplicate, err) + } + return activity +} + +func TestLatestWorkerDirectionsKeepsLatestPerIntentAcrossStoreReopen(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + task, err := d.CreateTask("worker direction persistence", "keep latest directions", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(task.ID) //nolint:errcheck + store := d.Exploration(task.ExplorationID) + firstIntent, err := store.AddIntent(map[string]any{"summary": "first route"}, 1, nil, "planner") + if err != nil { + t.Fatal(err) + } + secondIntent, err := store.AddIntent(map[string]any{"summary": "second route"}, 1, nil, "planner") + if err != nil { + t.Fatal(err) + } + + acceptTestWorkerDirection(t, store, firstIntent, "direction-old", "先检查旧方向") + secondActivity := acceptTestWorkerDirection(t, store, secondIntent, "direction-second", "保留第二条意图方向") + latestActivity := acceptTestWorkerDirection(t, store, firstIntent, "direction-latest", "改为验证最新权限边界") + + d2, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + defer d2.Close() + directions, err := d2.Exploration(task.ExplorationID).LatestWorkerDirections(10) + if err != nil { + t.Fatal(err) + } + if len(directions) != 2 { + t.Fatalf("directions=%+v, want one latest row per intent", directions) + } + byIntent := make(map[int64]WorkerDirection, len(directions)) + for _, direction := range directions { + byIntent[direction.IntentID] = direction + } + if got := byIntent[firstIntent]; got.ActivityID != latestActivity.ID || got.RequestID != "direction-latest" || + got.Message != "改为验证最新权限边界" || got.IntentSummary != "first route" { + t.Fatalf("latest first direction=%+v", got) + } + if got := byIntent[secondIntent]; got.ActivityID != secondActivity.ID || got.Message != "保留第二条意图方向" || + got.IntentSummary != "second route" { + t.Fatalf("second direction=%+v", got) + } +} diff --git a/server/intent_intervention_protocol_test.go b/server/intent_intervention_protocol_test.go index 8347eca..77ac2e7 100644 --- a/server/intent_intervention_protocol_test.go +++ b/server/intent_intervention_protocol_test.go @@ -181,6 +181,11 @@ func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing if accepted.err != nil || accepted.result.ActivityID <= pending.ActivityID || accepted.result.Duplicate || accepted.result.State != "open" { t.Fatalf("accepted Worker message=%+v control=%+v err=%v", accepted.result, pending, accepted.err) } + triggers := f.task.drainTriggers() + if len(triggers) != 1 || triggers[0].Kind != agent.TriggerWorkerMessage || + triggers[0].ActivityID != accepted.result.ActivityID || triggers[0].IntentID != f.intentID || triggers[0].Detail != message { + t.Fatalf("accepted Worker message triggers=%+v, want one matching persistent event", triggers) + } node, err := f.task.Store.GetNode(f.intentID) if err != nil || node == nil || node.State != "open" { t.Fatalf("reopened target=%+v err=%v", node, err) @@ -248,12 +253,39 @@ func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing if _, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, "同 request_id 的另一条消息"); !errors.Is(err, errWorkInterventionConflict) { t.Fatalf("same request_id different message error=%v, want conflict", err) } + if triggers := f.task.drainTriggers(); len(triggers) != 0 { + t.Fatalf("idempotent retries emitted Planner triggers: %+v", triggers) + } count, _ = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID) if count != 1 { t.Fatalf("engine retries appended %d Worker messages, want 1", count) } } +func TestRecoverAcceptedWorkerMessageReconstructsPlannerTrigger(t *testing.T) { + f := newInterventionProtocolFixture(t, 1) + const requestID = "worker-message-recovery-1" + const message = "恢复后优先验证人工指定的权限边界" + if _, err := f.task.Store.ReserveIntentIntervention(f.intentID, requestID, message); err != nil { + t.Fatal(err) + } + if changed, err := f.task.Store.CompareAndSetIntentState(f.intentID, "running", "paused"); err != nil || !changed { + t.Fatalf("pause before recovery acceptance: changed=%v err=%v", changed, err) + } + activity, duplicate, err := f.task.Store.AcceptIntentIntervention(f.intentID, requestID, message) + if err != nil || duplicate { + t.Fatalf("accept before recovery: activity=%+v duplicate=%v err=%v", activity, duplicate, err) + } + if err := f.engine.RecoverWorkerMessages(context.Background(), f.task); err != nil { + t.Fatal(err) + } + triggers := f.task.drainTriggers() + if len(triggers) != 1 || triggers[0].Kind != agent.TriggerWorkerMessage || triggers[0].ActivityID != activity.ID || + triggers[0].IntentID != f.intentID || triggers[0].Detail != message { + t.Fatalf("recovered Planner triggers=%+v", triggers) + } +} + func TestSendWorkerMessageContinuesAfterRequestContextCancellation(t *testing.T) { f := newInterventionProtocolFixture(t, 1) workCtx, cancelWork := context.WithCancelCause(context.Background()) From 76210b99336a1a7d10fa1283e386c837b2568870 Mon Sep 17 00:00:00 2001 From: pphua Date: Sat, 29 Aug 2026 22:05:28 +0800 Subject: [PATCH 5/6] revert: remove cross-agent worker direction synchronization --- agent/mainagent.go | 24 +-- agent/planner.go | 20 +-- agent/tools.go | 40 ----- agent/worker_direction_sync_test.go | 155 -------------------- db/exploration.go | 61 -------- db/intent_intervention_lifecycle_test.go | 69 --------- server/engine.go | 5 - server/intent_intervention_protocol_test.go | 32 ---- server/manager.go | 30 +--- 9 files changed, 11 insertions(+), 425 deletions(-) delete mode 100644 agent/worker_direction_sync_test.go diff --git a/agent/mainagent.go b/agent/mainagent.go index 2962252..9c40eca 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "fmt" "github.com/Autumn-27/artex/db" @@ -86,27 +85,6 @@ func mainAgentSystem(goal, dataDir, workDir string) string { return body + artifactSpec(workDir) } -// mainAgentInput keeps Worker-side human steering visible to the Main Agent on -// every fresh Chat session. The direction records remain separate from the -// current user turn so the model does not mistake persisted context for a new -// instruction that must be sent to the Worker again. -func mainAgentInput(ts *db.ExplorationStore, message string) string { - if ts == nil { - return message - } - directions, err := ts.LatestWorkerDirections(workerDirectionOverviewLimit) - if err != nil || len(directions) == 0 { - return message - } - raw, err := json.Marshal(compactWorkerDirections(directions)) - if err != nil { - return message - } - return "【系统同步的 Worker 最新人工方向】\n" + - "以下内容来自持久化的 Worker 对话。回答、观察和调度时必须纳入,但不要把它当作本轮新消息重复下发。\n" + - string(raw) + "\n\n【用户本轮消息】\n" + message -} - // Chat handles one human message and returns the assistant reply. emit, if // non-nil, receives each execution step (thinking / tool_use / tool_result / // text / result) so the main-agent session shows its work — exactly like the @@ -175,7 +153,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t // C2: this session is fresh each turn; re-unlock skill-gated MCPs from prior // Skill() calls in the reloaded history so revealed tools stay callable. seedUnlockFromHistory(s.Messages(), def.UnlockSkill) - text, _, err := captureRunSession(ctx, s, mainAgentInput(ts, message), func(r db.Activity) { + text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) { if emit != nil { r.Worker = "mainagent" emit(r) diff --git a/agent/planner.go b/agent/planner.go index 3f63166..95802f8 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -123,7 +123,6 @@ func renderPlannerTodos(items []actool.Todo) string { // // "done" — a worker finished intent IntentID (its output conclusion is fetched). // "finding" — a worker reported a finding on intent IntentID (Detail = 摘要). -// "worker_message" — the human changed one Worker's direction (Detail = 新方向). // "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a // single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量). // "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本). @@ -131,17 +130,14 @@ func renderPlannerTodos(items []actool.Todo) string { // "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is // stopped (not deleted) and the reason is attached to it as a fact. type TriggerEvent struct { - Kind string - ActivityID int64 // persistent activity id; worker_message notifications use it for de-duplication - IntentID int64 - Detail string - Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条) - OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本 - NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本 + Kind string + IntentID int64 + Detail string + Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条) + OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本 + NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本 } -const TriggerWorkerMessage = db.PlannerEventWorkerMessage - // renderTriggers spells out the change(s) that fired this round: for a finished // worker — which intent + its output conclusion; for a finding — which intent + // what was found. Empty for time/heartbeat wakes. Reads the store (best-effort; @@ -166,9 +162,6 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string { b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal)) case "finding": b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail)) - case TriggerWorkerMessage: - b.WriteString(fmt.Sprintf("\n- 用户向意图 #%d(%s)的 Worker 下发了新的人工方向:%s —— 该方向已优先交给同一 Worker;后续规划必须以它替代或补充原意图描述,避免生成冲突、回退或重复方向。", - ev.IntentID, intentSummary(ts, ev.IntentID), truncOutput(ev.Detail, 2000))) case "cancelled": b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已停止(不再执行),其原因已作为事实挂在该意图上;请据此重新规划。", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail)) default: // "done" @@ -257,7 +250,6 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" 决策流程(每次唤醒): 1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。 - open_intents / running_intents / recent_done_intents——"哪些方向已经有意图在覆盖/已尝试"。每个意图还带 **parents(上游:它派生自哪些事实/意图)和 yields(下游:它产生了哪些事实/发现)**——这就是探索图的**血缘关系**,据此理解"哪些事实来自哪个方向、能否综合成新方向"。recent_facts 里每个事实带 **from_intent**(由哪个意图产生)。 - - worker_directions——用户在 Worker 对话里对每个意图最近一次下发的人工方向(持久化、重启后仍存在)。它比该意图最初的 summary 更新;判断在跑方向、补意图和使用 steer/kill 时必须以它为准,禁止重新派发与人工方向冲突的工作。 - sites_without_endpoints / findings——"哪些方向【可能】需要探索"。 - 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。 2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。 diff --git a/agent/tools.go b/agent/tools.go index 3a34ca1..ebed6d8 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -48,38 +48,6 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[ return out } -const workerDirectionOverviewLimit = 300 -const workerDirectionMessageRunes = 1200 - -// compactWorkerDirections keeps the latest durable human direction for each -// intent small enough for every Planner/Main Agent turn. The full message stays -// available in the Worker activity trace when an unusually long direction is -// truncated here. -func compactWorkerDirections(items []db.WorkerDirection) []map[string]any { - out := make([]map[string]any, 0, len(items)) - for _, item := range items { - message := item.Message - truncated := false - if runes := []rune(message); len(runes) > workerDirectionMessageRunes { - message = string(runes[:workerDirectionMessageRunes]) + "…" - truncated = true - } - row := map[string]any{ - "activity_id": item.ActivityID, - "intent_id": item.IntentID, - "intent_state": item.IntentState, - "intent_summary": item.IntentSummary, - "message": message, - "updated_at": item.CreatedAt, - } - if truncated { - row["truncated"] = true - } - out = append(out, row) - } - return out -} - // ToolSet exposes the PG-backed dual graph (asset + exploration) to an LLM agent. // One ToolSet is created per planner/worker run; per-run signals live here. type ToolSet struct { @@ -392,14 +360,6 @@ func (t *ToolSet) graphOverview() actool.CoreTool { // an empty context and always needs this first). func (t *ToolSet) graphOverviewData() map[string]any { out := map[string]any{} - // worker_directions is the persistent cross-agent steering channel. It has - // one latest accepted human message per local intent, so Planner and Main - // Agent do not have to infer a changed direction from the original summary. - if directions, err := t.ts.LatestWorkerDirections(workerDirectionOverviewLimit); err == nil { - out["worker_directions"] = compactWorkerDirections(directions) - } else { - out["worker_directions"] = []map[string]any{} - } // goals summary folded in so the planner needn't call list_goals each round. goals, _ := t.ts.ListByKind(db.KindGoal, 100) gsum := make([]map[string]any, 0, len(goals)) diff --git a/agent/worker_direction_sync_test.go b/agent/worker_direction_sync_test.go deleted file mode 100644 index a1cf7f8..0000000 --- a/agent/worker_direction_sync_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package agent - -import ( - "context" - "iter" - "strings" - "sync" - "testing" - - "github.com/Autumn-27/artex/db" - "github.com/Autumn-27/norma/llm" -) - -type directionCaptureProvider struct { - mu sync.Mutex - requests []llm.CompletionRequest -} - -func (p *directionCaptureProvider) Stream(_ context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { - p.mu.Lock() - p.requests = append(p.requests, req) - p.mu.Unlock() - return func(yield func(llm.StreamEvent, error) bool) { - if !yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "同步完成"}, nil) { - return - } - if !yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil) { - return - } - yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil) - } -} - -func (p *directionCaptureProvider) Complete(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { - p.mu.Lock() - p.requests = append(p.requests, req) - p.mu.Unlock() - return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("同步完成")}}, "end_turn", llm.Usage{}, nil -} - -func (p *directionCaptureProvider) lastRequest(t *testing.T) llm.CompletionRequest { - t.Helper() - p.mu.Lock() - defer p.mu.Unlock() - if len(p.requests) == 0 { - t.Fatal("provider received no completion request") - } - return p.requests[len(p.requests)-1] -} - -func lastUserText(t *testing.T, req llm.CompletionRequest) string { - t.Helper() - for i := len(req.Messages) - 1; i >= 0; i-- { - if req.Messages[i].Role == llm.RoleUser { - return req.Messages[i].Text() - } - } - t.Fatal("completion request has no user message") - return "" -} - -type directionAgentFixture struct { - d *db.DB - task *db.Task - store *db.ExplorationStore - intentID int64 - activity db.Activity -} - -func newDirectionAgentFixture(t *testing.T) *directionAgentFixture { - t.Helper() - d := testDB(t) - task, err := d.CreateTask("agent direction sync", "synchronize worker steering", nil, 0, 0) - if err != nil { - d.Close() - t.Fatal(err) - } - t.Cleanup(func() { - _ = d.DeleteTask(task.ID) - _ = d.Close() - }) - store := d.Exploration(task.ExplorationID) - intentID, err := store.AddIntent(map[string]any{"summary": "原始接口枚举方向"}, 1, nil, "planner") - if err != nil { - t.Fatal(err) - } - if claimed, err := store.ClaimIntent(intentID, "work#direction"); err != nil || !claimed { - t.Fatalf("claim direction fixture: claimed=%v err=%v", claimed, err) - } - const requestID = "agent-direction-sync-1" - const direction = "停止接口枚举,立即聚焦登录后的水平越权" - if _, err := store.ReserveIntentIntervention(intentID, requestID, direction); err != nil { - t.Fatal(err) - } - if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed { - t.Fatalf("pause direction fixture: changed=%v err=%v", changed, err) - } - activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, direction) - if err != nil || duplicate { - t.Fatalf("accept direction fixture: activity=%+v duplicate=%v err=%v", activity, duplicate, err) - } - return &directionAgentFixture{d: d, task: task, store: store, intentID: intentID, activity: activity} -} - -func TestGraphOverviewIncludesLatestWorkerDirection(t *testing.T) { - f := newDirectionAgentFixture(t) - overview := NewToolSet(f.store, "planner").graphOverviewData() - directions, ok := overview["worker_directions"].([]map[string]any) - if !ok || len(directions) != 1 { - t.Fatalf("worker_directions=%T %+v", overview["worker_directions"], overview["worker_directions"]) - } - if directions[0]["activity_id"] != f.activity.ID || directions[0]["intent_id"] != f.intentID || - directions[0]["intent_summary"] != "原始接口枚举方向" || - directions[0]["message"] != "停止接口枚举,立即聚焦登录后的水平越权" { - t.Fatalf("worker direction overview=%+v", directions[0]) - } -} - -func TestPlannerRequestIncludesWorkerMessageTriggerAndPersistedDirection(t *testing.T) { - f := newDirectionAgentFixture(t) - provider := &directionCaptureProvider{} - planner := NewPlanner(provider, "test-model", t.TempDir(), nil, 0, 1) - triggerDetail := "本轮只验证登录后的对象级授权" - _, _, err := planner.Plan(context.Background(), f.task.ID, nil, f.store, f.task.Goal, []TriggerEvent{{ - Kind: TriggerWorkerMessage, ActivityID: f.activity.ID, IntentID: f.intentID, Detail: triggerDetail, - }}, nil) - if err != nil { - t.Fatal(err) - } - input := lastUserText(t, provider.lastRequest(t)) - for _, want := range []string{"【本次触发本轮的实际变动】", triggerDetail, `"worker_directions"`, "停止接口枚举,立即聚焦登录后的水平越权"} { - if !strings.Contains(input, want) { - t.Fatalf("Planner input missing %q:\n%s", want, input) - } - } -} - -func TestMainAgentNextTurnIncludesPersistedWorkerDirection(t *testing.T) { - f := newDirectionAgentFixture(t) - provider := &directionCaptureProvider{} - mainAgent := NewMainAgent(provider, "test-model", t.TempDir(), nil, 0, 1) - const userMessage = "汇报当前执行方向" - if _, err := mainAgent.Chat(context.Background(), f.task.ID, nil, f.store, f.task.Goal, userMessage, nil, nil, nil, nil); err != nil { - t.Fatal(err) - } - input := lastUserText(t, provider.lastRequest(t)) - for _, want := range []string{"【系统同步的 Worker 最新人工方向】", "停止接口枚举,立即聚焦登录后的水平越权", "【用户本轮消息】", userMessage} { - if !strings.Contains(input, want) { - t.Fatalf("Main Agent input missing %q:\n%s", want, input) - } - } - if strings.Index(input, "【系统同步的 Worker 最新人工方向】") > strings.Index(input, "【用户本轮消息】") { - t.Fatalf("Main Agent context is not separated before current user message:\n%s", input) - } -} diff --git a/db/exploration.go b/db/exploration.go index 6a05f9a..1ffb92a 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -339,7 +339,6 @@ const ( IntentInterventionPending = "pending" IntentInterventionAccepted = "accepted" IntentInterventionRejected = "rejected" - PlannerEventWorkerMessage = "worker_message" ) // IntentIntervention is the durable control record for one human interruption. @@ -356,19 +355,6 @@ type IntentIntervention struct { CreatedAt time.Time } -// WorkerDirection is the latest accepted human direction for one Worker -// intent. The accepted activity is the durable planning event, so this view -// survives restarts and is shared by Planner and Main Agent context builders. -type WorkerDirection struct { - ActivityID int64 - IntentID int64 - RequestID string - Message string - IntentState string - IntentSummary string - CreatedAt time.Time -} - // lockIntentIntervention serializes request-id reservation/finalization across // processes. exploration_nodes.id is a global primary key, so the intent id is // sufficient input to the namespaced advisory key. @@ -577,7 +563,6 @@ WHERE a.exploration_id=$1 AND a.node_id=$2 "intervention_request_id": requestID, "intervention_message": message, "intervention_status": IntentInterventionAccepted, - "planner_event": PlannerEventWorkerMessage, "source": "user", }) if err != nil { @@ -624,52 +609,6 @@ WHERE a.exploration_id=$1 AND a.node_id=$2 return activity, false, nil } -// LatestWorkerDirections returns at most one accepted human direction per -// local intent, newest directions first. It reads the visible user activity -// rather than the hidden hand-off row so archive/restore and activity history -// share one authoritative record. -func (s *ExplorationStore) LatestWorkerDirections(limit int) ([]WorkerDirection, error) { - if limit <= 0 { - limit = 300 - } - rows, err := s.db.Query(` -SELECT activity_id,intent_id,request_id,message,intent_state,intent_summary,created_at -FROM ( - SELECT DISTINCT ON (a.node_id) - a.id AS activity_id, - a.node_id AS intent_id, - COALESCE(a.metadata->>'intervention_request_id','') AS request_id, - COALESCE(a.metadata->>'intervention_message',a.detail,'') AS message, - n.state AS intent_state, - COALESCE(n.payload->>'summary','') AS intent_summary, - a.created_at - FROM activity a - JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id - WHERE a.exploration_id=$1 - AND a.kind='user' - AND n.kind='intent' - AND a.metadata->>'intervention_status'=$2 - AND a.metadata->>'planner_event'=$3 - ORDER BY a.node_id,a.id DESC -) latest -ORDER BY activity_id DESC -LIMIT $4`, s.expID, IntentInterventionAccepted, PlannerEventWorkerMessage, limit) - if err != nil { - return nil, err - } - defer rows.Close() - out := make([]WorkerDirection, 0) - for rows.Next() { - var item WorkerDirection - if err := rows.Scan(&item.ActivityID, &item.IntentID, &item.RequestID, &item.Message, - &item.IntentState, &item.IntentSummary, &item.CreatedAt); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() -} - // RecoverableIntentInterventions returns control operations that must be // reconstructed before ordinary frontier claiming after a process restart: // pending transcript commits, plus accepted hand-offs not yet claimed once. diff --git a/db/intent_intervention_lifecycle_test.go b/db/intent_intervention_lifecycle_test.go index 9363e7c..c27a746 100644 --- a/db/intent_intervention_lifecycle_test.go +++ b/db/intent_intervention_lifecycle_test.go @@ -202,72 +202,3 @@ func TestIntentInterventionAcceptanceCanRetryAfterStateSettlement(t *testing.T) t.Fatalf("retry acceptance: activity=%+v duplicate=%v err=%v", activity, duplicate, err) } } - -func acceptTestWorkerDirection(t *testing.T, store *ExplorationStore, intentID int64, requestID, message string) Activity { - t.Helper() - if claimed, err := store.ClaimIntent(intentID, "direction-test-worker"); err != nil || !claimed { - t.Fatalf("claim direction intent: claimed=%v err=%v", claimed, err) - } - if _, err := store.ReserveIntentIntervention(intentID, requestID, message); err != nil { - t.Fatal(err) - } - if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed { - t.Fatalf("pause direction intent: changed=%v err=%v", changed, err) - } - activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, message) - if err != nil || duplicate { - t.Fatalf("accept direction: activity=%+v duplicate=%v err=%v", activity, duplicate, err) - } - return activity -} - -func TestLatestWorkerDirectionsKeepsLatestPerIntentAcrossStoreReopen(t *testing.T) { - d, err := Open(testDSN(t)) - if err != nil { - t.Skipf("postgres unavailable (%v) - skipping", err) - } - defer d.Close() - task, err := d.CreateTask("worker direction persistence", "keep latest directions", nil, 0, 0) - if err != nil { - t.Fatal(err) - } - defer d.DeleteTask(task.ID) //nolint:errcheck - store := d.Exploration(task.ExplorationID) - firstIntent, err := store.AddIntent(map[string]any{"summary": "first route"}, 1, nil, "planner") - if err != nil { - t.Fatal(err) - } - secondIntent, err := store.AddIntent(map[string]any{"summary": "second route"}, 1, nil, "planner") - if err != nil { - t.Fatal(err) - } - - acceptTestWorkerDirection(t, store, firstIntent, "direction-old", "先检查旧方向") - secondActivity := acceptTestWorkerDirection(t, store, secondIntent, "direction-second", "保留第二条意图方向") - latestActivity := acceptTestWorkerDirection(t, store, firstIntent, "direction-latest", "改为验证最新权限边界") - - d2, err := Open(testDSN(t)) - if err != nil { - t.Fatal(err) - } - defer d2.Close() - directions, err := d2.Exploration(task.ExplorationID).LatestWorkerDirections(10) - if err != nil { - t.Fatal(err) - } - if len(directions) != 2 { - t.Fatalf("directions=%+v, want one latest row per intent", directions) - } - byIntent := make(map[int64]WorkerDirection, len(directions)) - for _, direction := range directions { - byIntent[direction.IntentID] = direction - } - if got := byIntent[firstIntent]; got.ActivityID != latestActivity.ID || got.RequestID != "direction-latest" || - got.Message != "改为验证最新权限边界" || got.IntentSummary != "first route" { - t.Fatalf("latest first direction=%+v", got) - } - if got := byIntent[secondIntent]; got.ActivityID != secondActivity.ID || got.Message != "保留第二条意图方向" || - got.IntentSummary != "second route" { - t.Fatalf("second direction=%+v", got) - } -} diff --git a/server/engine.go b/server/engine.go index 2754c09..165972d 100644 --- a/server/engine.go +++ b/server/engine.go @@ -687,7 +687,6 @@ func (e *Engine) interveneWork(ctx context.Context, t *Task, intentID int64, req } if !duplicate { e.publishStoredActivity(t, activity) - t.NotifyWorkerMessage(intentID, activity.ID, message) } e.readyDirectedClaim(intentID) t.NotifyWorker() @@ -752,10 +751,6 @@ func (e *Engine) recoverWorkerMessages(ctx context.Context, t *Task, allowAdmiss log.Printf("[worker chat] task %s 恢复意图 #%d 待处理消息失败: %v", t.ID, item.IntentID, recoverErr) } case db.IntentInterventionAccepted: - // Reconstruct the Planner-facing event while the Worker hand-off is still - // pending. The durable graph_overview view remains authoritative even if - // this notification was already consumed before a later restart. - t.NotifyWorkerMessage(item.IntentID, item.ActivityID, item.Message) if item.IntentState == "paused" { if changed, setErr := t.Store.CompareAndSetIntentState(item.IntentID, "paused", "open"); setErr != nil { return setErr diff --git a/server/intent_intervention_protocol_test.go b/server/intent_intervention_protocol_test.go index 77ac2e7..8347eca 100644 --- a/server/intent_intervention_protocol_test.go +++ b/server/intent_intervention_protocol_test.go @@ -181,11 +181,6 @@ func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing if accepted.err != nil || accepted.result.ActivityID <= pending.ActivityID || accepted.result.Duplicate || accepted.result.State != "open" { t.Fatalf("accepted Worker message=%+v control=%+v err=%v", accepted.result, pending, accepted.err) } - triggers := f.task.drainTriggers() - if len(triggers) != 1 || triggers[0].Kind != agent.TriggerWorkerMessage || - triggers[0].ActivityID != accepted.result.ActivityID || triggers[0].IntentID != f.intentID || triggers[0].Detail != message { - t.Fatalf("accepted Worker message triggers=%+v, want one matching persistent event", triggers) - } node, err := f.task.Store.GetNode(f.intentID) if err != nil || node == nil || node.State != "open" { t.Fatalf("reopened target=%+v err=%v", node, err) @@ -253,39 +248,12 @@ func TestWorkerMessageCancelsBeforeAcceptAndContinuesSameSessionFirst(t *testing if _, err := f.engine.InterveneWork(context.Background(), f.task, f.intentID, requestID, "同 request_id 的另一条消息"); !errors.Is(err, errWorkInterventionConflict) { t.Fatalf("same request_id different message error=%v, want conflict", err) } - if triggers := f.task.drainTriggers(); len(triggers) != 0 { - t.Fatalf("idempotent retries emitted Planner triggers: %+v", triggers) - } count, _ = countTranscriptWorkerMessages(t, f.tx, sessionID, requestID) if count != 1 { t.Fatalf("engine retries appended %d Worker messages, want 1", count) } } -func TestRecoverAcceptedWorkerMessageReconstructsPlannerTrigger(t *testing.T) { - f := newInterventionProtocolFixture(t, 1) - const requestID = "worker-message-recovery-1" - const message = "恢复后优先验证人工指定的权限边界" - if _, err := f.task.Store.ReserveIntentIntervention(f.intentID, requestID, message); err != nil { - t.Fatal(err) - } - if changed, err := f.task.Store.CompareAndSetIntentState(f.intentID, "running", "paused"); err != nil || !changed { - t.Fatalf("pause before recovery acceptance: changed=%v err=%v", changed, err) - } - activity, duplicate, err := f.task.Store.AcceptIntentIntervention(f.intentID, requestID, message) - if err != nil || duplicate { - t.Fatalf("accept before recovery: activity=%+v duplicate=%v err=%v", activity, duplicate, err) - } - if err := f.engine.RecoverWorkerMessages(context.Background(), f.task); err != nil { - t.Fatal(err) - } - triggers := f.task.drainTriggers() - if len(triggers) != 1 || triggers[0].Kind != agent.TriggerWorkerMessage || triggers[0].ActivityID != activity.ID || - triggers[0].IntentID != f.intentID || triggers[0].Detail != message { - t.Fatalf("recovered Planner triggers=%+v", triggers) - } -} - func TestSendWorkerMessageContinuesAfterRequestContextCancellation(t *testing.T) { f := newInterventionProtocolFixture(t, 1) workCtx, cancelWork := context.WithCancelCause(context.Background()) diff --git a/server/manager.go b/server/manager.go index d5c20e8..6fca4dc 100644 --- a/server/manager.go +++ b/server/manager.go @@ -1639,10 +1639,10 @@ func (t *Task) Notify() { } } -// NotifyWorker wakes one idle execution slot without itself scheduling a Planner -// round. Worker-message acceptance separately records a persistent planning event -// and calls NotifyWorkerMessage; keeping execution wake-up independent prevents -// Planner debounce from delaying the directed Worker claim. +// NotifyWorker wakes one idle execution slot without also scheduling a Planner +// round. The channel is separate from notify because human intervention needs +// the reopened intent reclaimed immediately, while graph-change notifications +// are Planner-facing and debounced. A nil channel is tolerated by test fixtures. func (t *Task) NotifyWorker() { if t == nil || t.workerWake == nil { return @@ -1653,28 +1653,6 @@ func (t *Task) NotifyWorker() { } } -// NotifyWorkerMessage immediately schedules a Planner round for an accepted, -// persistent Worker direction. ActivityID identifies the durable kind=user row -// and prevents retries in the same debounce window from duplicating the prompt. -func (t *Task) NotifyWorkerMessage(intentID, activityID int64, message string) { - message = strings.TrimSpace(message) - if intentID <= 0 || activityID <= 0 || message == "" { - return - } - t.trigMu.Lock() - for _, event := range t.pendingTriggers { - if event.Kind == agent.TriggerWorkerMessage && event.ActivityID == activityID { - t.trigMu.Unlock() - return - } - } - t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{ - Kind: agent.TriggerWorkerMessage, ActivityID: activityID, IntentID: intentID, Detail: message, - }) - t.trigMu.Unlock() - t.Notify() -} - // NotifyDone is Notify plus a hint: a worker just finished intentID and that is // what triggered this wake-up. The planner reads the accumulated triggers next // round so it can spell out which intent finished (+ its output). Events pile up From c9bf534818e38033a2f0d276454ccfa8c8a2b777 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:00:21 -0400 Subject: [PATCH 6/6] =?UTF-8?q?=E9=87=8D=E6=9E=84=20worker=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=EF=BC=9A=E5=A4=8D=E7=94=A8=20resume=20=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E6=94=B9=E4=B8=93=E5=B1=9E=20goroutine=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「给运行中 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)。 --- agent/cancelcause.go | 2 - db/exploration.go | 412 +------------ db/intent_intervention_lifecycle_test.go | 204 ------- server/engine.go | 559 +++--------------- server/engine_cancelcause_test.go | 1 - server/goals.go | 7 - server/intent_intervention.go | 65 +- server/intent_intervention_protocol_test.go | 378 ------------ server/manager.go | 17 +- server/server.go | 8 - server/worker_message_test.go | 54 ++ .../tasks/detail/_tabs/sessions-tab.tsx | 72 +-- web/src/lib/api.ts | 3 +- web/src/lib/mock/handler.ts | 23 +- 14 files changed, 237 insertions(+), 1568 deletions(-) delete mode 100644 db/intent_intervention_lifecycle_test.go delete mode 100644 server/intent_intervention_protocol_test.go create mode 100644 server/worker_message_test.go diff --git a/agent/cancelcause.go b/agent/cancelcause.go index e61cfaf..f4705bc 100644 --- a/agent/cancelcause.go +++ b/agent/cancelcause.go @@ -45,8 +45,6 @@ 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", diff --git a/db/exploration.go b/db/exploration.go index 1ffb92a..eb0e677 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -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 { diff --git a/db/intent_intervention_lifecycle_test.go b/db/intent_intervention_lifecycle_test.go deleted file mode 100644 index c27a746..0000000 --- a/db/intent_intervention_lifecycle_test.go +++ /dev/null @@ -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) - } -} diff --git a/server/engine.go b/server/engine.go index 165972d..b0b0a56 100644 --- a/server/engine.go +++ b/server/engine.go @@ -75,7 +75,6 @@ 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 @@ -131,17 +130,6 @@ 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 @@ -174,14 +162,7 @@ type taskRuntime struct { type workExecution struct { cancel context.CancelCauseFunc done chan error - action string // user action: pause | cancel | intervene -} - -type WorkInterventionResult struct { - RequestID string - ActivityID int64 - State string - Duplicate bool + action string // user action: pause | cancel } // nextPlannerRound returns the next planner round number for a task (1-based). @@ -304,12 +285,6 @@ 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() @@ -415,7 +390,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{}, - directedClaims: map[string][]int64{}, directedReady: map[int64]bool{}, runtimes: map[string]*taskRuntime{}} + runtimes: map[string]*taskRuntime{}} } // registerWork records the cancel for the work currently running intentID. @@ -450,7 +425,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" && action != "intervene" { + if action != "pause" && action != "cancel" { return fmt.Errorf("unsupported work action %q", action) } if ctx == nil { @@ -469,11 +444,8 @@ func (e *Engine) ControlWork(ctx context.Context, intentID int64, action string) run.action = action done := run.done cause := error(agent.AbortWorkPausedByUser) - switch action { - case "cancel": + if action == "cancel" { cause = agent.AbortWorkCancelledByUser - case "intervene": - cause = agent.AbortWorkIntervenedByUser } run.cancel(cause) e.workMu.Unlock() @@ -492,282 +464,6 @@ 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) - } - 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: - 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 @@ -775,11 +471,7 @@ func (e *Engine) recoverWorkerMessages(ctx context.Context, t *Task, allowAdmiss func (e *Engine) releaseWorkControl(intentID int64, run *workExecution, action string) { e.workMu.Lock() if current := e.work[intentID]; current == run && current.action == action { - if action == "intervene" { - current.action = "intervene_no_handoff" - } else { - current.action = "" - } + current.action = "" } e.workMu.Unlock() } @@ -918,19 +610,6 @@ 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 @@ -1197,13 +876,13 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) { } _, worker := e.snapshotFor(t) if worker == nil { - if waitWorker(ctx, t, 1500*time.Millisecond) { + if sleepCtx(ctx, 1500*time.Millisecond) { return } continue } if e.IsPaused(t.ID) { - if waitWorker(ctx, t, 1000*time.Millisecond) { + if sleepCtx(ctx, 1000*time.Millisecond) { return } continue // user-paused: don't claim/execute intents @@ -1212,13 +891,13 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) { return } if e.isSettling(t.ID) { - if waitWorker(ctx, t, 1000*time.Millisecond) { + if sleepCtx(ctx, 1000*time.Millisecond) { return } continue // 任务超时收尾中:不再领新意图(在跑的自行收尾,协调器等其 drain) } if isTerminalStatus(e.m.TaskStatus(t.ID)) { - if waitWorker(ctx, t, 1000*time.Millisecond) { + if sleepCtx(ctx, 1000*time.Millisecond) { return } continue // 任务已终态(done/failed/timeout):停止领取遗留意图,别在完成后空跑 frontier @@ -1228,21 +907,34 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) { } claimed := e.runWorkerStep(ctx, t, name, worker) e.decInflight(t.ID) - if !claimed && waitWorker(ctx, t, 800*time.Millisecond) { + if !claimed && sleepCtx(ctx, 800*time.Millisecond) { return } } } -// runWorkerStep claims and fully settles at most one intent. Its caller holds one -// task-operation admission for the whole claim/execute/state-write sequence so a -// delete cannot observe quiescence between the LLM return and the final DB writes. +// runWorkerStep claims one intent from the frontier and fully settles it via +// runIntent. Returns false when nothing was claimable. The pool worker loop is its +// only caller. func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker *agent.Worker) bool { intent := e.claimNext(t, name) if intent == nil { return false } log.Printf("[worker %s] task %s 领取意图 #%d", name, t.ID, intent.ID) + return e.runIntent(ctx, t, name, worker, intent, "", "") +} + +// runIntent executes and fully settles one already-claimed (state=running) intent. +// Both the pool worker loop (via runWorkerStep) and the human-message handler (via +// runDetachedIntent, a dedicated goroutine outside the worker pool) call it, so the +// execute/retry/state-write logic lives in exactly one place. A non-empty message +// is injected as this turn's input through ExecuteWithMessage; requestID keys the +// transcript marker that dedups re-injection across model_error retries. The caller +// must already hold one task-operation admission for the whole sequence so a delete +// cannot observe quiescence between the LLM return and the final DB writes. +func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *agent.Worker, intent *db.Node, requestID, message string) bool { + hasChatMessage := message != "" e.stampFirstRun(t) // 首次真正执行 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务) e.touch(t.ID) emit := func(r db.Activity) { e.emitActivity(t, r) } @@ -1267,31 +959,16 @@ 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) 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) + reason, wrote, err = worker.ExecuteWithMessage(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding, requestID, message) } 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 的优雅收尾窗口)。 @@ -1305,7 +982,7 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker } e.BeginLLMCall(t.ID) 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) + reason, wrote, err = worker.ExecuteWithMessage(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding, requestID, message) } else { reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding) } @@ -1326,8 +1003,6 @@ 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" } @@ -1344,22 +1019,6 @@ 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 @@ -1441,6 +1100,61 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker return true } +// runDetachedIntent runs one paused intent OUTSIDE the worker pool in its own +// goroutine — the human-message path. It transitions the intent paused->running +// itself (never through 'open'), so the pool, which only claims 'open', can never +// race it; the "at most one run per intent" invariant still holds because winning +// the CAS is the sole entry and work[intentID] was cleared when the pause settled. +// Because it does not compete for a frontier slot, a user message continues the +// worker immediately even when all pool slots are busy (mirroring how the +// main-agent chat handler starts its run directly). The spawned goroutine owns one +// task-operation admission for the whole run and roots its context at ctx (pass the +// server root, never the HTTP request, so a disconnect cannot strand the run while +// task pause/delete/shutdown still stops it). Returns an error if the run could not +// be started; the intent is left untouched in that case. +func (e *Engine) runDetachedIntent(ctx context.Context, t *Task, intentID int64, requestID, message string) error { + if !e.beginTaskOperation(t.ID) { + return fmt.Errorf("task is being deleted") + } + release := true + defer func() { + if release { + e.decInflight(t.ID) + } + }() + _, worker := e.snapshotFor(t) + if worker == nil { + return fmt.Errorf("worker 尚未就绪") + } + node, err := t.Store.GetNode(intentID) + if err != nil { + return err + } + if node == nil || node.Kind != db.KindIntent { + return fmt.Errorf("intent not found") + } + changed, err := t.Store.CompareAndSetIntentState(intentID, "paused", "running") + if err != nil { + return err + } + if !changed { + return fmt.Errorf("%w: 意图不再是 paused 状态", db.ErrIntentStateConflict) + } + node.State, node.Owner = "running", "chat" + // Record the human turn as a visible activity BEFORE the run starts, so it is + // ordered ahead of any worker step and never appears without the run happening. + // This is the UI copy; ExecuteWithMessage separately writes it into the intent + // transcript as the LLM input. + uid := intentID + e.emitActivity(t, db.Activity{NodeID: &uid, Worker: "user", Kind: "user", Summary: message, Detail: message}) + release = false // ownership of the admission passes to the goroutine + go func() { + defer e.decInflight(t.ID) + e.runIntent(ctx, t, "chat", worker, node, requestID, message) + }() + return nil +} + func taskExecutionPaused(cause error) bool { var abort *agent.AbortCause if !errors.As(cause, &abort) { @@ -1464,114 +1178,7 @@ 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 { diff --git a/server/engine_cancelcause_test.go b/server/engine_cancelcause_test.go index 63c7324..d1f4f7a 100644 --- a/server/engine_cancelcause_test.go +++ b/server/engine_cancelcause_test.go @@ -45,7 +45,6 @@ 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) diff --git a/server/goals.go b/server/goals.go index 05f2c7e..c8e6d98 100644 --- a/server/goals.go +++ b/server/goals.go @@ -225,13 +225,6 @@ 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 diff --git a/server/intent_intervention.go b/server/intent_intervention.go index 6df99fa..3e3655c 100644 --- a/server/intent_intervention.go +++ b/server/intent_intervention.go @@ -1,13 +1,11 @@ package server import ( - "context" "encoding/json" "errors" "net/http" "strconv" "strings" - "time" "github.com/Autumn-27/artex/db" ) @@ -28,9 +26,15 @@ func validWorkerMessageRequestID(id string) bool { 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. +// sendWorkerMessage continues a paused Worker intent with a human-authored message. +// The message is injected as the next turn's input through the same +// resume-from-transcript path the worker uses on a normal resume (ExecuteWithMessage), +// so this reuses the existing pause/resume machinery rather than a bespoke protocol. +// The run happens in a dedicated goroutine outside the worker pool (runDetachedIntent), +// so the message is picked up immediately even when every pool slot is busy — the same +// way the main-agent chat handler starts its run directly. In-memory only: a process +// restart re-runs the intent from its transcript without the message, which is +// acceptable for this rare interrupt-then-continue action. func (s *Server) sendWorkerMessage(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { @@ -71,11 +75,28 @@ func (s *Server) sendWorkerMessage(w http.ResponseWriter, r *http.Request) { return } - if !s.engine.beginTaskOperation(t.ID) { + // Reject non-runnable task lifecycles up front so the caller gets a clear reason + // instead of a silent no-op. The intent itself must be paused: the UI flow is + // interrupt (pause) first, then send. + if s.engine.IsDeleting(t.ID) { writeErr(w, http.StatusConflict, "任务正在删除,无法向 Worker 发送消息") return } - defer s.engine.decInflight(t.ID) + lifecycle := t.lifecycleSnapshot() + switch { + case lifecycle.Paused || s.engine.IsPaused(t.ID): + writeErr(w, http.StatusConflict, "任务已暂停,请先恢复任务再向 Worker 发送消息") + return + case lifecycle.Queued: + writeErr(w, http.StatusConflict, "排队中的任务无法向 Worker 发送消息") + return + case isTerminalStatus(lifecycle.Status): + writeErr(w, http.StatusConflict, "终态任务无法向 Worker 发送消息") + return + case s.engine.isSettling(t.ID): + writeErr(w, http.StatusConflict, "任务正在收尾,无法向 Worker 发送消息") + return + } node, err := t.Store.GetNode(iid) if err != nil { @@ -94,19 +115,17 @@ func (s *Server) sendWorkerMessage(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusConflict, "node is not an intent") return } - base := s.ctx - if base == nil { - base = context.Background() + if node.State != "paused" { + writeErr(w, http.StatusConflict, "仅已暂停的 Worker 可以发送消息,请先暂停") + return } - opCtx, cancel := context.WithTimeout(base, workControlWaitTimeout+15*time.Second) - defer cancel() - result, err := s.engine.InterveneWork(opCtx, t, iid, requestID, message) - if err != nil { + + // runDetachedIntent transitions paused->running, emits the user turn and starts a + // dedicated run. Root the run at s.ctx so a disconnected browser cannot strand it + // while task pause/delete/shutdown still stop it. + if err := s.engine.runDetachedIntent(s.ctx, t, iid, requestID, message); 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): + case errors.Is(err, db.ErrIntentStateConflict): writeErr(w, http.StatusConflict, err.Error()) default: writeErr(w, http.StatusInternalServerError, err.Error()) @@ -115,11 +134,9 @@ func (s *Server) sendWorkerMessage(w http.ResponseWriter, r *http.Request) { } 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, + "id": iid, + "state": "running", + "accepted": true, + "request_id": requestID, }) } diff --git a/server/intent_intervention_protocol_test.go b/server/intent_intervention_protocol_test.go deleted file mode 100644 index 8347eca..0000000 --- a/server/intent_intervention_protocol_test.go +++ /dev/null @@ -1,378 +0,0 @@ -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()) - } -} diff --git a/server/manager.go b/server/manager.go index 6fca4dc..cb198c2 100644 --- a/server/manager.go +++ b/server/manager.go @@ -63,7 +63,6 @@ type Task struct { Store *pgdb.ExplorationStore `json:"-"` Guard *guard.Guard `json:"-"` notify chan struct{} - workerWake chan struct{} lifecycleMu sync.RWMutex llmMu sync.RWMutex @@ -738,7 +737,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), workerWake: make(chan struct{}, 64), + Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), } } @@ -1639,20 +1638,6 @@ func (t *Task) Notify() { } } -// NotifyWorker wakes one idle execution slot without also scheduling a Planner -// round. The channel is separate from notify because human intervention needs -// the reopened intent reclaimed immediately, while graph-change notifications -// are Planner-facing and debounced. A nil channel is tolerated by test fixtures. -func (t *Task) NotifyWorker() { - if t == nil || t.workerWake == nil { - return - } - select { - case t.workerWake <- struct{}{}: - default: - } -} - // 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 diff --git a/server/server.go b/server/server.go index b6a4182..f820b1e 100644 --- a/server/server.go +++ b/server/server.go @@ -249,9 +249,6 @@ 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) } @@ -474,11 +471,6 @@ 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 { diff --git a/server/worker_message_test.go b/server/worker_message_test.go new file mode 100644 index 0000000..9487ba5 --- /dev/null +++ b/server/worker_message_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "context" + "errors" + "strings" + "testing" +) + +// After the worker-message rework, the human-message path no longer goes through +// ControlWork with an "intervene" action — it runs the intent in a dedicated +// goroutine (runDetachedIntent) and pauses, if needed, with the ordinary "pause" +// action. Guard that ControlWork only knows pause|cancel so a reintroduced +// "intervene" caller fails loudly instead of silently no-op'ing. +func TestControlWorkRejectsRemovedInterveneAction(t *testing.T) { + e := NewEngine(nil) + err := e.ControlWork(context.Background(), 1, "intervene") + if err == nil || !containsUnsupported(err) { + t.Fatalf("intervene should be unsupported, got %v", err) + } +} + +// A control request for an intent with no live work is a conflict, not a panic or +// a silent success — the same guard the message handler relies on when a run has +// already finished between the UI read and the request. +func TestControlWorkConflictsWhenNoWorkRegistered(t *testing.T) { + e := NewEngine(nil) + err := e.ControlWork(context.Background(), 42, "pause") + if !errors.Is(err, errWorkControlConflict) { + t.Fatalf("pause with no work = %v, want errWorkControlConflict", err) + } +} + +// validWorkerMessageRequestID gates the idempotency token; keep its charset stable +// so a client-generated UUID is always accepted and injection-y ids are rejected. +func TestValidWorkerMessageRequestID(t *testing.T) { + ok := []string{"worker-message-abc123", "a.b:c_d-1", "550e8400-e29b-41d4-a716-446655440000"} + for _, id := range ok { + if !validWorkerMessageRequestID(id) { + t.Errorf("id %q should be valid", id) + } + } + bad := []string{"", "has space", "emoji😀", "slash/here", string(make([]byte, 129))} + for _, id := range bad { + if validWorkerMessageRequestID(id) { + t.Errorf("id %q should be rejected", id) + } + } +} + +func containsUnsupported(err error) bool { + return err != nil && !errors.Is(err, errWorkControlConflict) && + strings.Contains(err.Error(), "unsupported work action") +} diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 46ed163..3ea1ffb 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -1336,14 +1336,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { 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 (!intentId || active.inherited || active.status !== "paused" || workerMessageSending || !message) return; if (workerMessageCharCount(message) > MAX_WORKER_MESSAGE_CHARS) { toast.error(`消息不能超过 ${MAX_WORKER_MESSAGE_CHARS} 个字符`); return; @@ -1354,23 +1347,8 @@ export function SessionsTab({ taskId }: { taskId: string }) { 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, - })); + // The server records the user turn and streams the run over SSE, so there is + // no optimistic insert: the message and the continuation arrive live. patchIntentState(intentId, result.state); setWorkerMessage(""); setWorkerMessageRequestId(""); @@ -1788,7 +1766,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
) : active.role === "worker" && !active.inherited && - (active.status === "running" || active.status === "paused" || active.status === "pending") ? ( + (active.status === "running" || active.status === "paused") ? (
) : ( - MAX_WORKER_MESSAGE_CHARS - } - title="发送消息" - aria-label="发送消息" - > - {workerMessageSending ? : } - + <> + void controlWorker(active, "resume")} + disabled={controllingIntent === active.intent_id || workerMessageSending} + title="不发消息,直接继续执行" + aria-label="直接继续执行" + > + {controllingIntent === active.intent_id ? : "直接继续"} + + MAX_WORKER_MESSAGE_CHARS + } + title="发送消息" + aria-label="发送消息" + > + {workerMessageSending ? : } + + )} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4516702..f2b5b3f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -287,9 +287,8 @@ export const api = { sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) => post<{ id: number; - state: "open" | "running" | "paused"; + state: "running"; accepted: true; - activity_seq: number; request_id: string; }>(`/tasks/${taskId}/intents/${intentId}/messages`, { message, request_id: requestId }), taskLLMResolution: (id: string) => get(`/tasks/${id}/llm/resolution`), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 98b6c44..efa66f5 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -465,23 +465,23 @@ function sendMockWorkerMessage( } 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 (intent.inherited || 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"; + // The real endpoint transitions the intent paused->running, records the user turn, + // and runs it in a dedicated goroutine outside the worker pool. Keep the mock in + // sync: flip to running immediately and emit the visible user activity. + intent.state = "running"; const activitySeq = nextMockWorkerMessageActivitySeq++; const workerMessage: MockWorkerMessage = { intentId: id, message: normalizedMessage, - state: "open", + state: "running", activitySeq, }; mockWorkerMessages.set(normalizedRequestId, workerMessage); @@ -494,12 +494,6 @@ function sendMockWorkerMessage( summary: normalizedMessage, detail: normalizedMessage, } satisfies Activity); - setTimeout(() => { - if (intent.state === "open") { - intent.state = "running"; - workerMessage.state = "running"; - } - }, 250); return { ok: true, state: workerMessage.state, @@ -1008,9 +1002,8 @@ 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 ?? "open", + state: result.state ?? "running", accepted: true, - activity_seq: result.activitySeq ?? 0, request_id: result.requestId ?? "", }; }