++
This commit is contained in:
@@ -35,6 +35,8 @@ type AgentDirectoryItem struct {
|
||||
MCPStatus string `json:"mcpStatus"`
|
||||
MCPFailure string `json:"mcpFailure"`
|
||||
LastDeploymentAt string `json:"lastDeploymentAt"`
|
||||
RestartStatus string `json:"restartStatus"`
|
||||
RestartFailure string `json:"restartFailure"`
|
||||
}
|
||||
|
||||
type AgentDirectoryPage struct {
|
||||
@@ -85,11 +87,13 @@ var agentDirectoryBaseSQL = `WITH skill_summary AS (
|
||||
` + mcpStatusExpression("a.agent_id") + ` AS mcp_status,
|
||||
` + mcpErrorExpression("a.agent_id") + ` AS mcp_failure,
|
||||
COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), '') AS mcp_updated_at,
|
||||
MAX(COALESCE(l.updated_at, ''), COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), ''), COALESCE(s.updated_at, '')) AS last_deployment_at
|
||||
MAX(COALESCE(l.updated_at, ''), COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), ''), COALESCE(s.updated_at, '')) AS last_deployment_at,
|
||||
COALESCE(r.status, '') AS restart_status, COALESCE(r.failure, '') AS restart_failure
|
||||
FROM managed_agents a
|
||||
LEFT JOIN agent_llm_assignments l ON l.agent_id = a.agent_id
|
||||
LEFT JOIN llm_profiles p ON p.profile_id = l.profile_id
|
||||
LEFT JOIN skill_summary s ON s.agent_id = a.agent_id
|
||||
LEFT JOIN agent_restart_operations r ON r.agent_id = a.agent_id
|
||||
)
|
||||
`
|
||||
|
||||
@@ -164,7 +168,7 @@ func (c *ControlPlane) AgentDirectory(filter AgentDirectoryFilter) (AgentDirecto
|
||||
}
|
||||
queryArgs := append(append([]any{}, args...), filter.PageSize, (page.Page-1)*filter.PageSize)
|
||||
rows, err := c.db.Query(agentDirectoryBaseSQL+`SELECT agent_id, name, status, last_seen_at, llm_profile, llm_status, llm_failure,
|
||||
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at
|
||||
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at, restart_status, restart_failure
|
||||
FROM directory`+where+" ORDER BY CASE status WHEN 'online' THEN 0 ELSE 1 END, name COLLATE NOCASE, agent_id LIMIT ? OFFSET ?", queryArgs...)
|
||||
if err != nil {
|
||||
return page, fmt.Errorf("无法读取 Agent 列表: %w", err)
|
||||
@@ -172,7 +176,7 @@ func (c *ControlPlane) AgentDirectory(filter AgentDirectoryFilter) (AgentDirecto
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item AgentDirectoryItem
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt, &item.RestartStatus, &item.RestartFailure); err != nil {
|
||||
return page, err
|
||||
}
|
||||
item.LLMFailure, item.SkillFailure, item.MCPFailure = sanitizeDisplayText(item.LLMFailure), sanitizeDisplayText(item.SkillFailure), sanitizeDisplayText(item.MCPFailure)
|
||||
@@ -189,8 +193,8 @@ func (c *ControlPlane) AgentOperationalDetails(agentID string) (AgentOperational
|
||||
// Fetch by ID directly so duplicate display names cannot select another Agent.
|
||||
var item AgentDirectoryItem
|
||||
err := c.db.QueryRow(agentDirectoryBaseSQL+`SELECT agent_id, name, status, last_seen_at, llm_profile, llm_status, llm_failure,
|
||||
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at FROM directory WHERE agent_id = ?`, agentID).Scan(
|
||||
&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt,
|
||||
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at, restart_status, restart_failure FROM directory WHERE agent_id = ?`, agentID).Scan(
|
||||
&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt, &item.RestartStatus, &item.RestartFailure,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AgentOperationalDetail{}, errors.New("未找到 Agent")
|
||||
@@ -198,7 +202,7 @@ func (c *ControlPlane) AgentOperationalDetails(agentID string) (AgentOperational
|
||||
if err != nil {
|
||||
return AgentOperationalDetail{}, fmt.Errorf("无法读取 Agent 详情: %w", err)
|
||||
}
|
||||
detail := AgentOperationalDetail{Agent: Agent{ID: item.ID, Name: item.Name, Status: item.Status, LastSeenAt: item.LastSeenAt}, Deployment: item, RecentTasks: make([]Task, 0), RecentAudit: make([]AuditEvent, 0), DeploymentHistory: make([]AuditEvent, 0), Failures: make([]DeploymentFailure, 0)}
|
||||
detail := AgentOperationalDetail{Agent: Agent{ID: item.ID, Name: item.Name, Status: item.Status, LastSeenAt: item.LastSeenAt, RestartStatus: item.RestartStatus, RestartFailure: item.RestartFailure}, Deployment: item, RecentTasks: make([]Task, 0), RecentAudit: make([]AuditEvent, 0), DeploymentHistory: make([]AuditEvent, 0), Failures: make([]DeploymentFailure, 0)}
|
||||
detail.RecentTasks, err = c.recentTasksForAgent(agentID, 8)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
|
||||
@@ -50,10 +50,12 @@ type Snapshot struct {
|
||||
|
||||
// Agent is a safe summary of a registered Agent.
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
RestartStatus string `json:"restartStatus"`
|
||||
RestartFailure string `json:"restartFailure"`
|
||||
}
|
||||
|
||||
// Task is a persisted controller task summary. Credentials never appear here.
|
||||
@@ -323,7 +325,8 @@ func (c *ControlPlane) removeRevokedAgents() {
|
||||
}
|
||||
|
||||
func (c *ControlPlane) queryAgents(where string) ([]Agent, error) {
|
||||
rows, err := c.db.Query("SELECT agent_id, name, status, last_seen_at FROM managed_agents " + where + " ORDER BY name")
|
||||
where = strings.ReplaceAll(where, "status", "a.status")
|
||||
rows, err := c.db.Query("SELECT a.agent_id, a.name, a.status, a.last_seen_at, COALESCE(r.status, ''), COALESCE(r.failure, '') FROM managed_agents a LEFT JOIN agent_restart_operations r ON r.agent_id = a.agent_id " + where + " ORDER BY a.name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -331,7 +334,7 @@ func (c *ControlPlane) queryAgents(where string) ([]Agent, error) {
|
||||
agents := make([]Agent, 0)
|
||||
for rows.Next() {
|
||||
var agent Agent
|
||||
if err := rows.Scan(&agent.ID, &agent.Name, &agent.Status, &agent.LastSeenAt); err != nil {
|
||||
if err := rows.Scan(&agent.ID, &agent.Name, &agent.Status, &agent.LastSeenAt, &agent.RestartStatus, &agent.RestartFailure); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agents = append(agents, agent)
|
||||
@@ -511,10 +514,20 @@ func (c *ControlPlane) CancelTask(taskID string) error {
|
||||
if client == nil {
|
||||
return errors.New("目标 Agent 当前不在线,暂不能取消")
|
||||
}
|
||||
// Persist the cancellation intent before sending. An Agent can respond on
|
||||
// another goroutine immediately; recording it first makes every late
|
||||
// completed/failed event ineligible to overwrite the requested outcome.
|
||||
result, err := c.db.Exec("UPDATE task_runs SET status = 'cancelling' WHERE task_id = ? AND status = ?", taskID, status)
|
||||
if err != nil {
|
||||
return errors.New("无法记录取消状态,请刷新后重试")
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("任务状态已变化,请刷新后重试")
|
||||
}
|
||||
if err := client.send(map[string]any{"type": "task_cancel", "schema_version": protocolVersion, "task_id": taskID, "reason": "由总控取消"}); err != nil {
|
||||
_, _ = c.db.Exec("UPDATE task_runs SET status = ? WHERE task_id = ? AND status = 'cancelling'", status, taskID)
|
||||
return errors.New("取消指令未发送成功,请刷新后重试")
|
||||
}
|
||||
_, _ = c.db.Exec("UPDATE task_runs SET status = 'cancelling' WHERE task_id = ?", taskID)
|
||||
c.recordEvent(taskID, "cancelling", "已向 Agent 发送取消指令。", time.Now().UTC().Format(time.RFC3339Nano))
|
||||
c.recordAudit("task.cancel_requested", "controller", "agent:"+agentID, "cancelling", "已向 Agent 请求取消任务。")
|
||||
return nil
|
||||
@@ -723,6 +736,10 @@ func (c *ControlPlane) handleMessage(client *connection, message map[string]any)
|
||||
if client.agentID != "" {
|
||||
c.applyMCPAck(client.agentID, message)
|
||||
}
|
||||
case "agent_restart_ack":
|
||||
if client.agentID != "" {
|
||||
c.applyRestartAck(client.agentID, message)
|
||||
}
|
||||
default:
|
||||
_ = client.send(map[string]any{"type": "error", "message": "不支持的消息类型。"})
|
||||
}
|
||||
@@ -738,6 +755,7 @@ func (c *ControlPlane) attach(client *connection, agentID string) {
|
||||
_ = previous.ws.Close()
|
||||
}
|
||||
c.setAgentStatus(agentID, "online")
|
||||
c.markRestartReconnected(agentID)
|
||||
}
|
||||
|
||||
// pushConfigurationSnapshots starts only after the authentication acknowledgement
|
||||
@@ -877,6 +895,12 @@ func (c *ControlPlane) applyTaskEvent(agentID string, event map[string]any) {
|
||||
if c.db.QueryRow("SELECT agent_id, status, timeout_seconds, COALESCE(session_id, ''), instruction FROM task_runs WHERE task_id = ?", taskID).Scan(&expectedAgentID, ¤tStatus, &timeoutSeconds, &sessionID, &instruction) != nil || expectedAgentID != agentID || isTerminal(currentStatus) {
|
||||
return
|
||||
}
|
||||
// Cancellation is a write-once intent. An already-running model or MCP
|
||||
// request may return late, but it must not change the requested terminal
|
||||
// outcome to completed/failed or resurrect the task as running.
|
||||
if currentStatus == "cancelling" && status != "cancelled" {
|
||||
return
|
||||
}
|
||||
message, _ := event["message"].(string)
|
||||
output, _ := event["output"].(string)
|
||||
errText, _ := event["error"].(string)
|
||||
@@ -1065,6 +1089,7 @@ func initialiseSchema(db *sql.DB) error {
|
||||
"CREATE TABLE IF NOT EXISTS task_sessions (session_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, turn_limit INTEGER NOT NULL DEFAULT 50, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS task_session_messages (session_id TEXT NOT NULL, position INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(session_id, position))",
|
||||
"CREATE TABLE IF NOT EXISTS audit_events (event_id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, actor TEXT NOT NULL, target TEXT NOT NULL, outcome TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, operator TEXT NOT NULL DEFAULT '', target_agent_id TEXT NOT NULL DEFAULT '', resource_type TEXT NOT NULL DEFAULT 'system', result TEXT NOT NULL DEFAULT '', security_detail TEXT NOT NULL DEFAULT '')",
|
||||
"CREATE TABLE IF NOT EXISTS agent_restart_operations (agent_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, status TEXT NOT NULL, requested_at TEXT NOT NULL, confirmed_at TEXT, failure TEXT NOT NULL DEFAULT '')",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const restartCooldown = 60 * time.Second
|
||||
|
||||
// AgentRestart is the safe lifecycle state rendered by the controller. It
|
||||
// contains no remote process details or credentials.
|
||||
type AgentRestart struct {
|
||||
RequestID string `json:"requestID"`
|
||||
AgentID string `json:"agentID"`
|
||||
Status string `json:"status"`
|
||||
RequestedAt string `json:"requestedAt"`
|
||||
ConfirmedAt string `json:"confirmedAt"`
|
||||
Failure string `json:"failure"`
|
||||
}
|
||||
|
||||
// RestartAgent sends an authenticated command over the existing paired WSS
|
||||
// session. The controller deliberately never starts or terminates a remote
|
||||
// machine process itself.
|
||||
func (c *ControlPlane) RestartAgent(agentID string) (AgentRestart, error) {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
return AgentRestart{}, errors.New("Agent 标识无效")
|
||||
}
|
||||
c.expireStaleAgents()
|
||||
var status string
|
||||
if err := c.db.QueryRow("SELECT status FROM managed_agents WHERE agent_id = ?", agentID).Scan(&status); err != nil {
|
||||
return AgentRestart{}, errors.New("未找到 Agent")
|
||||
}
|
||||
client := c.clientFor(agentID)
|
||||
if status != "online" || client == nil {
|
||||
c.recordAudit("remote.restart_rejected", "controller", "agent:"+agentID, "failed", "目标 Agent 当前离线,无法通过已认证连接发送重启指令。")
|
||||
return AgentRestart{}, errors.New("目标 Agent 当前离线,无法重启;请等待其重新连接")
|
||||
}
|
||||
var existing AgentRestart
|
||||
err := c.db.QueryRow("SELECT request_id, agent_id, status, requested_at, COALESCE(confirmed_at, ''), COALESCE(failure, '') FROM agent_restart_operations WHERE agent_id = ?", agentID).Scan(&existing.RequestID, &existing.AgentID, &existing.Status, &existing.RequestedAt, &existing.ConfirmedAt, &existing.Failure)
|
||||
if err == nil {
|
||||
if existing.Status == "restarting" || existing.Status == "sent" {
|
||||
return existing, nil // idempotent repeated click; do not send another command.
|
||||
}
|
||||
if existing.Status == "reconnected" {
|
||||
if requested, parseErr := time.Parse(time.RFC3339Nano, existing.RequestedAt); parseErr == nil && time.Since(requested) < restartCooldown {
|
||||
return existing, errors.New("该 Agent 刚完成重启,请稍后再试")
|
||||
}
|
||||
}
|
||||
}
|
||||
requestID, err := randomHex(16)
|
||||
if err != nil {
|
||||
return AgentRestart{}, err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
op := AgentRestart{RequestID: requestID, AgentID: agentID, Status: "restarting", RequestedAt: now}
|
||||
if _, err := c.db.Exec(`INSERT INTO agent_restart_operations(request_id, agent_id, status, requested_at, confirmed_at, failure)
|
||||
VALUES (?, ?, ?, ?, '', '') ON CONFLICT(agent_id) DO UPDATE SET request_id=excluded.request_id, status=excluded.status, requested_at=excluded.requested_at, confirmed_at='', failure=''`, requestID, agentID, op.Status, now); err != nil {
|
||||
return AgentRestart{}, fmt.Errorf("无法记录重启操作: %w", err)
|
||||
}
|
||||
c.recordAudit("remote.restart_requested", "controller", "agent:"+agentID, "restarting", "已通过认证控制连接请求 Agent 重启。")
|
||||
if err := client.send(map[string]any{"type": "agent_restart", "schema_version": protocolVersion, "request_id": requestID, "sent_at": now}); err != nil {
|
||||
_, _ = c.db.Exec("UPDATE agent_restart_operations SET status='failed', failure=? WHERE agent_id=? AND request_id=?", "重启指令未发送成功。", agentID, requestID)
|
||||
c.recordAudit("remote.restart_delivery", "controller", "agent:"+agentID, "failed", "重启指令未发送成功。")
|
||||
return AgentRestart{}, errors.New("重启指令未发送成功,请刷新后重试")
|
||||
}
|
||||
c.recordAudit("remote.restart_delivery", "controller", "agent:"+agentID, "sent", "重启指令已下发,等待 Agent 确认。")
|
||||
return op, nil
|
||||
}
|
||||
|
||||
func (c *ControlPlane) applyRestartAck(agentID string, message map[string]any) {
|
||||
requestID, _ := message["request_id"].(string)
|
||||
status, _ := message["status"].(string)
|
||||
errorText, _ := message["error"].(string)
|
||||
if requestID == "" || (status != "accepted" && status != "restarting" && status != "failed") {
|
||||
return
|
||||
}
|
||||
var expected string
|
||||
if c.db.QueryRow("SELECT request_id FROM agent_restart_operations WHERE agent_id=?", agentID).Scan(&expected) != nil || expected != requestID {
|
||||
return
|
||||
}
|
||||
if status == "failed" {
|
||||
detail := limit(sanitizeDisplayText(errorText), 1000)
|
||||
if detail == "" {
|
||||
detail = "Agent 未能启动替代进程。"
|
||||
}
|
||||
_, _ = c.db.Exec("UPDATE agent_restart_operations SET status='failed', confirmed_at=?, failure=? WHERE agent_id=?", time.Now().UTC().Format(time.RFC3339Nano), detail, agentID)
|
||||
c.recordAudit("remote.restart_confirmed", "agent:"+agentID, "agent:"+agentID, "failed", detail)
|
||||
return
|
||||
}
|
||||
_, _ = c.db.Exec("UPDATE agent_restart_operations SET status='restarting', confirmed_at=?, failure='' WHERE agent_id=?", time.Now().UTC().Format(time.RFC3339Nano), agentID)
|
||||
c.recordAudit("remote.restart_confirmed", "agent:"+agentID, "agent:"+agentID, "restarting", "Agent 已确认正在安全结束任务并重启。")
|
||||
}
|
||||
|
||||
func (c *ControlPlane) markRestartReconnected(agentID string) {
|
||||
result, err := c.db.Exec("UPDATE agent_restart_operations SET status='reconnected', confirmed_at=?, failure='' WHERE agent_id=? AND status IN ('sent', 'restarting')", time.Now().UTC().Format(time.RFC3339Nano), agentID)
|
||||
if err == nil {
|
||||
if changed, _ := result.RowsAffected(); changed == 1 {
|
||||
c.recordAudit("remote.restart_reconnected", "agent:"+agentID, "agent:"+agentID, "reconnected", "替代 Agent 已重新通过认证并恢复连接。")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func newRestartTestController(t *testing.T) (*ControlPlane, string) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
_ = listener.Close()
|
||||
configPath := filepath.Join(t.TempDir(), "controller-config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"agent_host":"127.0.0.1","agent_port":`+strconv.Itoa(port)+`}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
|
||||
controller, err := Open(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := controller.StartAgentServer(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return controller, "wss://127.0.0.1:" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
func pairRestartTestAgent(t *testing.T, controller *ControlPlane, url string) (*websocket.Conn, string, string) {
|
||||
t.Helper()
|
||||
client, _, err := (&websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}).Dial(url, nil) //nolint:gosec -- test-only TLS.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, err := controller.CreatePairingCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.WriteJSON(map[string]any{"type": "pair_request", "code": code, "agent_name": "restart-agent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paired map[string]any
|
||||
if err := client.ReadJSON(&paired); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return client, paired["agent_id"].(string), paired["credential"].(string)
|
||||
}
|
||||
|
||||
func TestRestartOnlineAgentIsIdempotentAndReconnects(t *testing.T) {
|
||||
controller, url := newRestartTestController(t)
|
||||
defer controller.Close()
|
||||
client, agentID, credential := pairRestartTestAgent(t, controller, url)
|
||||
defer client.Close()
|
||||
|
||||
first, err := controller.RestartAgent(agentID)
|
||||
if err != nil || first.Status != "restarting" {
|
||||
t.Fatalf("restart request failed: %#v / %v", first, err)
|
||||
}
|
||||
var command map[string]any
|
||||
if err := client.ReadJSON(&command); err != nil || command["type"] != "agent_restart" || command["request_id"] != first.RequestID {
|
||||
t.Fatalf("expected restart command, got %#v / %v", command, err)
|
||||
}
|
||||
second, err := controller.RestartAgent(agentID)
|
||||
if err != nil || second.RequestID != first.RequestID {
|
||||
t.Fatalf("duplicate must reuse operation: %#v / %v", second, err)
|
||||
}
|
||||
if err := client.WriteJSON(map[string]any{"type": "agent_restart_ack", "request_id": first.RequestID, "status": "accepted"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
_ = client.Close()
|
||||
|
||||
replacement, _, err := (&websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}).Dial(url, nil) //nolint:gosec -- test-only TLS.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replacement.Close()
|
||||
if err := replacement.WriteJSON(map[string]any{"type": "authenticate", "agent_id": agentID, "credential": credential, "agent_name": "restart-agent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var accepted map[string]any
|
||||
if err := replacement.ReadJSON(&accepted); err != nil || accepted["type"] != "auth_accepted" {
|
||||
t.Fatalf("replacement did not reconnect: %#v / %v", accepted, err)
|
||||
}
|
||||
var status string
|
||||
if err := controller.db.QueryRow("SELECT status FROM agent_restart_operations WHERE agent_id=?", agentID).Scan(&status); err != nil || status != "reconnected" {
|
||||
t.Fatalf("restart state = %q / %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartOfflineAgentIsRejectedAndLateCompletionCannotWin(t *testing.T) {
|
||||
controller, _ := newRestartTestController(t)
|
||||
defer controller.Close()
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('offline', 'offline', 'hash', 'offline', ?)", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := controller.RestartAgent("offline"); err == nil {
|
||||
t.Fatal("offline Agent restart must be rejected")
|
||||
}
|
||||
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, timeout_seconds) VALUES ('cancelled-late', 'offline', 'offline', 'test', 'cancelling', ?, 30)", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller.applyTaskEvent("offline", map[string]any{"task_id": "cancelled-late", "status": "completed", "output": "late"})
|
||||
var taskStatus string
|
||||
if err := controller.db.QueryRow("SELECT status FROM task_runs WHERE task_id='cancelled-late'").Scan(&taskStatus); err != nil || taskStatus != "cancelling" {
|
||||
t.Fatalf("late completion overwrote cancellation: %q / %v", taskStatus, err)
|
||||
}
|
||||
controller.applyTaskEvent("offline", map[string]any{"task_id": "cancelled-late", "status": "cancelled", "message": "已取消"})
|
||||
if err := controller.db.QueryRow("SELECT status FROM task_runs WHERE task_id='cancelled-late'").Scan(&taskStatus); err != nil || taskStatus != "cancelled" {
|
||||
t.Fatalf("cancel acknowledgement missing: %q / %v", taskStatus, err)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// same limit so content is never silently truncated after deployment.
|
||||
const maxSkillContentBytes = 50000
|
||||
|
||||
// Skill is a safe Markdown-only capability guide owned by the controller.
|
||||
// Skill is a Markdown-only capability guide owned by the controller.
|
||||
type Skill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -114,7 +114,7 @@ func (c *ControlPlane) SaveSkill(input SkillInput) (Skill, error) {
|
||||
if _, err = c.db.Exec("INSERT INTO skill_packages(skill_id, name, version, content, checksum, updated_at) VALUES (?, ?, ?, ?, ?, ?)", skillID, name, version, content, checksum, now); err != nil {
|
||||
return Skill{}, fmt.Errorf("无法保存 Skill: %w", err)
|
||||
}
|
||||
c.recordAudit("skill.package_saved", "controller", "skill:"+skillID, "completed", "已保存经哈希校验的 Skill 包。")
|
||||
c.recordAudit("skill.package_saved", "controller", "skill:"+skillID, "completed", "已保存 Skill 包。")
|
||||
case err != nil:
|
||||
return Skill{}, fmt.Errorf("无法读取 Skill: %w", err)
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user