From a4c8bbb03cf323ea7d8167e04450ecfc91a38e47 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 30 Mar 2026 02:41:51 +0800 Subject: [PATCH] fix(handler): attribute agent CLI actions to agent identity When agents use the multica CLI during task execution, their comments, issue updates, and issue creations were attributed to the daemon's user (via JWT) instead of the agent. Pass MULTICA_AGENT_ID env var from the daemon, send X-Agent-ID header from the CLI client, and use it in handlers to set the correct author/actor identity. --- server/cmd/multica/cmd_agent.go | 7 ++++++- server/internal/cli/client.go | 4 ++++ server/internal/daemon/daemon.go | 1 + server/internal/handler/comment.go | 22 +++++++++++++++++----- server/internal/handler/issue.go | 28 ++++++++++++++++++++++++---- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index b89579b9..aad41210 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -39,7 +39,12 @@ func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url '") } - return cli.NewAPIClient(serverURL, workspaceID, token), nil + client := cli.NewAPIClient(serverURL, workspaceID, token) + // When running inside a daemon task, attribute actions to the agent. + if agentID := os.Getenv("MULTICA_AGENT_ID"); agentID != "" { + client.AgentID = agentID + } + return client, nil } func resolveServerURL(cmd *cobra.Command) string { diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 40840f7a..88734636 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -21,6 +21,7 @@ type APIClient struct { BaseURL string WorkspaceID string Token string + AgentID string // When set, requests are attributed to this agent instead of the user. HTTPClient *http.Client } @@ -41,6 +42,9 @@ func (c *APIClient) setHeaders(req *http.Request) { if c.WorkspaceID != "" { req.Header.Set("X-Workspace-ID", c.WorkspaceID) } + if c.AgentID != "" { + req.Header.Set("X-Agent-ID", c.AgentID) + } } // GetJSON performs a GET request and decodes the JSON response. diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 155f536e..e7b71fc6 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -662,6 +662,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR "MULTICA_DAEMON_PORT": fmt.Sprintf("%d", d.cfg.HealthPort), "MULTICA_WORKSPACE_ID": d.workspaceIDForRuntime(task.RuntimeID), "MULTICA_AGENT_NAME": agentName, + "MULTICA_AGENT_ID": task.AgentID, "MULTICA_TASK_ID": task.ID, } // Point Codex to the per-task CODEX_HOME so it discovers skills natively diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 0e00c472..59e9c34c 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -101,10 +101,22 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { } } + // Determine author identity: agent (via X-Agent-ID header) or member. + authorType := "member" + authorID := userID + if agentID := r.Header.Get("X-Agent-ID"); agentID != "" { + // Validate the agent exists in this workspace. + agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)) + if err == nil && uuidToString(agent.WorkspaceID) == uuidToString(issue.WorkspaceID) { + authorType = "agent" + authorID = agentID + } + } + comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{ IssueID: issue.ID, - AuthorType: "member", - AuthorID: parseUUID(userID), + AuthorType: authorType, + AuthorID: parseUUID(authorID), Content: req.Content, Type: req.Type, ParentID: parentID, @@ -117,7 +129,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { resp := commentToResponse(comment) slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...) - h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{ + h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{ "comment": resp, "issue_title": issue.Title, "issue_assignee_type": textToPtr(issue.AssigneeType), @@ -126,8 +138,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { }) // If the issue is assigned to an agent with on_comment trigger, enqueue a new task. - // The agent will resume its prior session and see this comment. - if h.shouldEnqueueOnComment(r.Context(), issue) { + // Skip when the comment comes from the assigned agent itself to avoid loops. + if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) { if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue); err != nil { slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err) } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 4a7b1ecc..659717d4 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -220,6 +220,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { return } + // Determine creator identity: agent (via X-Agent-ID header) or member. + creatorType := "member" + actualCreatorID := creatorID + if agentID := r.Header.Get("X-Agent-ID"); agentID != "" { + if agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)); err == nil && uuidToString(agent.WorkspaceID) == workspaceID { + creatorType = "agent" + actualCreatorID = agentID + } + } + issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{ WorkspaceID: parseUUID(workspaceID), Title: req.Title, @@ -228,8 +238,8 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { Priority: priority, AssigneeType: assigneeType, AssigneeID: assigneeID, - CreatorType: "member", - CreatorID: parseUUID(creatorID), + CreatorType: creatorType, + CreatorID: parseUUID(actualCreatorID), ParentIssueID: parentIssueID, Position: 0, DueDate: dueDate, @@ -249,7 +259,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) resp := issueToResponse(issue, prefix) slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...) - h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp}) + h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp}) // Only ready issues in todo are enqueued for agents. if issue.AssigneeType.Valid && issue.AssigneeID.Valid { @@ -371,7 +381,17 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) || (prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate) - h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{ + // Determine actor identity: agent (via X-Agent-ID header) or member. + actorType := "member" + actorID := userID + if agentID := r.Header.Get("X-Agent-ID"); agentID != "" { + if agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)); err == nil && uuidToString(agent.WorkspaceID) == workspaceID { + actorType = "agent" + actorID = agentID + } + } + + h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{ "issue": resp, "assignee_changed": assigneeChanged, "status_changed": statusChanged,