xworkmate-bridge/internal/acp/jobs.go
Cowork 3P d436729508 refactor: remove multi-agent orchestration engine, add coding standards
- Delete internal/acp/multi_agent.go (610 lines) — Path B orchestration
- Remove multiAgent dispatch from orchestrator, jobs, http_handler
- Set multiAgent/multi_agent capabilities to false in catalog
- Remove TaskKindMultiAgent from types
- Remove redundant ErrorResponse() wrapper in shared/rpc.go
- Add anti-fallback coding standards to AGENTS.md
2026-06-04 07:40:23 +00:00

355 lines
10 KiB
Go

package acp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"xworkmate-bridge/internal/shared"
)
const (
defaultJobTimeout = 10 * time.Minute
defaultWebhookMaxTries = 3
defaultWebhookRetryWait = 2 * time.Second
)
type jobManager struct {
server *Server
mu sync.Mutex
jobs map[string]*bridgeJob
}
type bridgeJob struct {
JobID string
SessionID string
ThreadID string
ProviderID string
Prompt string
WorkingDir string
Status string
Result map[string]any
Error string
CreatedAt time.Time
StartedAt time.Time
CompletedAt time.Time
Timeout time.Duration
CallbackURL string
WebhookTries int
WebhookSent bool
Target string
Channel string
AccountID string
Params map[string]any
}
func newJobManager(server *Server) *jobManager {
return &jobManager{server: server, jobs: make(map[string]*bridgeJob)}
}
func (s *Server) handleJobMethod(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
if s.jobs == nil {
s.jobs = newJobManager(s)
}
switch method {
case "xworkmate.jobs.submit":
return s.jobs.submit(ctx, params, notify), nil
case "xworkmate.jobs.get":
return s.jobs.get(params), nil
case "xworkmate.jobs.list":
return s.jobs.list(), nil
case "xworkmate.jobs.stats":
return s.jobs.stats(), nil
default:
return nil, &shared.RPCError{Code: -32601, Message: "unknown jobs method: " + method}
}
}
func (m *jobManager) submit(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
m.failStuck()
jobID := fmt.Sprintf("job-%d", time.Now().UnixNano())
providerID := strings.TrimSpace(firstNonEmptyString(params, "providerId", "agent", "provider"))
routing := shared.AsMap(params["routing"])
if providerID == "" {
providerID = strings.TrimSpace(shared.StringArg(routing, "explicitProviderId", ""))
}
timeout := time.Duration(parsePositiveInt(params["timeoutMs"])) * time.Millisecond
if timeout <= 0 {
timeout = defaultJobTimeout
}
job := &bridgeJob{
JobID: jobID,
SessionID: strings.TrimSpace(shared.StringArg(params, "sessionId", jobID)),
ThreadID: strings.TrimSpace(shared.StringArg(params, "threadId", jobID)),
ProviderID: providerID,
Prompt: strings.TrimSpace(shared.StringArg(params, "taskPrompt", shared.StringArg(params, "prompt", ""))),
WorkingDir: strings.TrimSpace(shared.StringArg(params, "workingDirectory", shared.StringArg(params, "cwd", ""))),
Status: "pending",
CreatedAt: time.Now(),
Timeout: timeout,
CallbackURL: strings.TrimSpace(shared.StringArg(params, "callbackUrl", shared.StringArg(params, "webhookUrl", ""))),
Target: strings.TrimSpace(shared.StringArg(params, "target", shared.StringArg(params, "discordTarget", ""))),
Channel: strings.TrimSpace(shared.StringArg(params, "channel", "discord")),
AccountID: strings.TrimSpace(shared.StringArg(params, "accountId", "")),
Params: cloneJobParams(params),
}
m.mu.Lock()
m.jobs[job.JobID] = job
m.mu.Unlock()
go m.run(ctx, job, notify)
return map[string]any{"jobId": job.JobID, "status": job.Status, "sessionId": job.SessionID, "providerId": job.ProviderID}
}
func (m *jobManager) run(parent context.Context, job *bridgeJob, notify func(map[string]any)) {
m.setRunning(job)
ctx, cancel := context.WithTimeout(parent, job.Timeout)
defer cancel()
params := cloneJobParams(job.Params)
params["sessionId"] = job.SessionID
params["threadId"] = job.ThreadID
params["taskPrompt"] = job.Prompt
params["workingDirectory"] = job.WorkingDir
if job.ProviderID != "" {
params["routing"] = map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": job.ProviderID,
}
}
result, rpcErr := m.server.orchestrator.Process(ctx, "session.start", params, notify)
m.mu.Lock()
defer m.mu.Unlock()
job.CompletedAt = time.Now()
if rpcErr != nil {
job.Status = "failed"
job.Error = rpcErr.Message
} else {
job.Result = result
if parseBool(result["success"]) {
job.Status = "completed"
} else {
job.Status = "failed"
job.Error = firstNonEmptyString(result, "error", "message", "unavailableMessage")
}
}
go m.sendCallbacks(job)
}
func (m *jobManager) setRunning(job *bridgeJob) {
m.mu.Lock()
defer m.mu.Unlock()
job.Status = "running"
job.StartedAt = time.Now()
}
func (m *jobManager) get(params map[string]any) map[string]any {
m.failStuck()
jobID := strings.TrimSpace(shared.StringArg(params, "jobId", ""))
m.mu.Lock()
defer m.mu.Unlock()
if job := m.jobs[jobID]; job != nil {
return job.mapPayload()
}
return map[string]any{"status": "not_found", "jobId": jobID}
}
func (m *jobManager) list() map[string]any {
m.failStuck()
m.mu.Lock()
defer m.mu.Unlock()
jobs := make([]map[string]any, 0, len(m.jobs))
counts := map[string]int{"pending": 0, "running": 0, "completed": 0, "failed": 0}
for _, job := range m.jobs {
jobs = append(jobs, job.mapPayload())
counts[job.Status]++
}
summary := make(map[string]any, len(counts))
for key, value := range counts {
summary[key] = value
}
return map[string]any{"jobs": jobs, "summary": summary}
}
func (m *jobManager) stats() map[string]any {
summary := m.list()["summary"]
return map[string]any{"summary": summary}
}
func (m *jobManager) failStuck() {
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
for _, job := range m.jobs {
if job.Status != "running" && job.Status != "pending" {
continue
}
timeout := job.Timeout
if timeout <= 0 {
timeout = defaultJobTimeout
}
if now.Sub(job.CreatedAt) > timeout {
job.Status = "failed"
job.Error = "job exceeded timeout"
job.CompletedAt = now
go m.sendCallbacks(job)
}
}
}
func (m *jobManager) sendCallbacks(job *bridgeJob) {
if job == nil {
return
}
if job.CallbackURL != "" {
m.sendWebhook(job)
}
if job.Target != "" {
_, _ = m.server.invokeOpenClawTool(context.Background(), map[string]any{
"tool": "message",
"action": "send",
"channel": job.Channel,
"accountId": job.AccountID,
"args": map[string]any{
"channel": job.Channel,
"target": job.Target,
"message": job.markdownCard(),
},
})
}
}
func (m *jobManager) sendWebhook(job *bridgeJob) {
payload, _ := json.Marshal(job.mapPayload())
for attempt := 1; attempt <= defaultWebhookMaxTries; attempt++ {
req, err := http.NewRequest(http.MethodPost, job.CallbackURL, bytes.NewReader(payload))
if err != nil {
break
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
job.WebhookTries = attempt
if err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
job.WebhookSent = true
if resp.Body != nil {
_ = resp.Body.Close()
}
return
}
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
_ = resp.Body.Close()
}
time.Sleep(defaultWebhookRetryWait)
}
}
func (j *bridgeJob) mapPayload() map[string]any {
payload := map[string]any{
"jobId": j.JobID,
"status": j.Status,
"sessionId": j.SessionID,
"threadId": j.ThreadID,
"providerId": j.ProviderID,
"createdAt": j.CreatedAt.UTC().Format(time.RFC3339Nano),
"webhookSent": j.WebhookSent,
"webhookTries": j.WebhookTries,
}
if !j.StartedAt.IsZero() {
payload["startedAt"] = j.StartedAt.UTC().Format(time.RFC3339Nano)
payload["elapsedMs"] = time.Since(j.StartedAt).Milliseconds()
}
if !j.CompletedAt.IsZero() {
payload["completedAt"] = j.CompletedAt.UTC().Format(time.RFC3339Nano)
payload["durationMs"] = j.CompletedAt.Sub(j.CreatedAt).Milliseconds()
}
if j.Result != nil {
payload["result"] = j.Result
}
if j.Error != "" {
payload["error"] = j.Error
}
return payload
}
func (j *bridgeJob) markdownCard() string {
title := fmt.Sprintf("### XWorkmate job %s: %s", j.JobID, j.Status)
if j.Result != nil {
if summary := firstNonEmptyString(j.Result, "summary", "output", "message"); summary != "" {
return title + "\n\n" + summary
}
}
if j.Error != "" {
return title + "\n\n" + j.Error
}
return title
}
func (s *Server) invokeOpenClawTool(ctx context.Context, params map[string]any) (map[string]any, *shared.RPCError) {
payload := map[string]any{
"tool": strings.TrimSpace(shared.StringArg(params, "tool", "")),
"action": strings.TrimSpace(shared.StringArg(params, "action", "")),
"args": shared.AsMap(params["args"]),
}
if payload["tool"] == "" {
return nil, &shared.RPCError{Code: -32602, Message: "TOOL_REQUIRED"}
}
toolURL := strings.TrimSpace(os.Getenv("OPENCLAW_TOOLS_INVOKE_URL"))
if toolURL != "" {
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, toolURL, bytes.NewReader(body))
if err != nil {
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
}
req.Header.Set("Content-Type", "application/json")
if token := strings.TrimSpace(os.Getenv("OPENCLAW_TOOLS_TOKEN")); token != "" {
req.Header.Set("Authorization", bearerHeader(token))
}
response, err := http.DefaultClient.Do(req)
if err != nil {
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
}
defer func() { _ = response.Body.Close() }()
var decoded map[string]any
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return decoded, &shared.RPCError{Code: -32002, Message: "tools invoke failed"}
}
return decoded, nil
}
if s.gateway == nil {
return nil, &shared.RPCError{Code: -32001, Message: "GATEWAY_NOT_INITIALIZED"}
}
if rpcErr := ensureProductionGatewayConnected(s, "openclaw", nil); rpcErr != nil {
return nil, rpcErr
}
result := s.gateway.RequestByMode("openclaw", "tools.invoke", payload, 30*time.Second, nil)
if !result.OK {
return nil, gatewayRPCError(result.Error, "tools invoke failed")
}
return shared.AsMap(result.Payload), nil
}
func bearerHeader(token string) string {
if strings.HasPrefix(strings.ToLower(token), "bearer ") {
return token
}
return "Bearer " + token
}
func cloneJobParams(params map[string]any) map[string]any {
next := make(map[string]any, len(params))
for key, value := range params {
next[key] = value
}
return next
}