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,