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.
This commit is contained in:
Jiayuan 2026-03-30 02:41:51 +08:00
parent 663dec52b8
commit a4c8bbb03c
5 changed files with 52 additions and 10 deletions

View file

@ -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 <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 {

View file

@ -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.

View file

@ -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

View file

@ -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)
}

View file

@ -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,