Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
b268c2f994 fix(quick-create): subscribe requester to issues created via quick-create
The agent runs the daemon CLI, so issue.creator_type is `agent` and the
issue:created event listener only auto-subscribes the agent — not the
human requester. Result: the requester gets a single completion inbox
item but never sees follow-up comments or updates on their own issue.

Subscribe the requester (reason=`creator`, the only matching value
allowed by issue_subscriber's CHECK constraint without a migration)
inside notifyQuickCreateCompleted, after the issue lookup succeeds and
before the inbox write. Best-effort: log on failure, don't block the
inbox. On success, publish subscriber:added so the UI stays in sync
with manual subscribe and the listener-driven path.

Adds two integration tests in cmd/server: success path subscribes the
requester; failure path (agent finished without creating an issue)
leaves no subscriber rows.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 13:13:18 +08:00
2 changed files with 179 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
package main
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestQuickCreateCompletion_SubscribesRequester locks in the fix for the
// quick-create requester not being subscribed to the issue: the agent runs
// the CLI and is recorded as the issue's creator, so the issue:created event
// only auto-subscribes the agent. The completion path must explicitly
// subscribe the human requester so they receive follow-up notifications.
func TestQuickCreateCompletion_SubscribesRequester(t *testing.T) {
ctx := context.Background()
queries := db.New(testPool)
bus := events.New()
taskSvc := service.NewTaskService(queries, testPool, nil, bus)
var agentID string
if err := testPool.QueryRow(ctx,
`SELECT id::text FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&agentID); err != nil {
t.Fatalf("load fixture agent: %v", err)
}
task, err := taskSvc.EnqueueQuickCreateTask(ctx,
parseUUID(testWorkspaceID),
parseUUID(testUserID),
parseUUID(agentID),
"please file a bug",
)
if err != nil {
t.Fatalf("EnqueueQuickCreateTask: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, task.ID)
})
if _, err := testPool.Exec(ctx,
`UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() WHERE id = $1`,
task.ID,
); err != nil {
t.Fatalf("dispatch task: %v", err)
}
if _, err := queries.StartAgentTask(ctx, task.ID); err != nil {
t.Fatalf("StartAgentTask: %v", err)
}
number, err := queries.IncrementIssueCounter(ctx, parseUUID(testWorkspaceID))
if err != nil {
t.Fatalf("IncrementIssueCounter: %v", err)
}
issue, err := queries.CreateIssueWithOrigin(ctx, db.CreateIssueWithOriginParams{
WorkspaceID: parseUUID(testWorkspaceID),
Title: "agent-filed bug",
Status: "todo",
Priority: "none",
CreatorType: "agent",
CreatorID: parseUUID(agentID),
Number: number,
OriginType: pgtype.Text{String: "quick_create", Valid: true},
OriginID: task.ID,
})
if err != nil {
t.Fatalf("CreateIssueWithOrigin: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issue.ID)
})
if _, err := taskSvc.CompleteTask(ctx, task.ID, []byte(`{"output":"done"}`), "", ""); err != nil {
t.Fatalf("CompleteTask: %v", err)
}
if !isSubscribed(t, queries, util.UUIDToString(issue.ID), "member", testUserID) {
t.Fatal("expected requester to be subscribed after quick-create completion")
}
}
// TestQuickCreateFailure_DoesNotSubscribeRequester confirms the failure path
// (agent finished without producing an issue) does not invent a subscriber
// row — there is nothing to subscribe to.
func TestQuickCreateFailure_DoesNotSubscribeRequester(t *testing.T) {
ctx := context.Background()
queries := db.New(testPool)
bus := events.New()
taskSvc := service.NewTaskService(queries, testPool, nil, bus)
var agentID string
if err := testPool.QueryRow(ctx,
`SELECT id::text FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&agentID); err != nil {
t.Fatalf("load fixture agent: %v", err)
}
task, err := taskSvc.EnqueueQuickCreateTask(ctx,
parseUUID(testWorkspaceID),
parseUUID(testUserID),
parseUUID(agentID),
"another bug",
)
if err != nil {
t.Fatalf("EnqueueQuickCreateTask: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, task.ID)
})
if _, err := testPool.Exec(ctx,
`UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() WHERE id = $1`,
task.ID,
); err != nil {
t.Fatalf("dispatch task: %v", err)
}
if _, err := queries.StartAgentTask(ctx, task.ID); err != nil {
t.Fatalf("StartAgentTask: %v", err)
}
// No issue with origin_type=quick_create + this task id exists. Completion
// hits the failure branch and writes a failure inbox; no subscriber row.
if _, err := taskSvc.CompleteTask(ctx, task.ID, []byte(`{"output":"done"}`), "", ""); err != nil {
t.Fatalf("CompleteTask: %v", err)
}
var leaked int
if err := testPool.QueryRow(ctx, `
SELECT COUNT(*)
FROM issue_subscriber s
JOIN issue i ON i.id = s.issue_id
WHERE s.user_type = 'member' AND s.user_id = $1
AND i.origin_type = 'quick_create' AND i.origin_id = $2
`, testUserID, task.ID).Scan(&leaked); err != nil {
t.Fatalf("count leaked subscribers: %v", err)
}
if leaked != 0 {
t.Fatalf("expected no subscriber rows for failed quick-create, got %d", leaked)
}
}

View File

@@ -1421,6 +1421,39 @@ func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.Ag
"error", err,
)
}
// Subscribe the requester so they receive notifications for follow-up
// comments and updates. The DB row's creator_type/creator_id is the
// agent (it ran the CLI), but the human who triggered the quick-create
// is the semantic creator from a UX perspective — without this they
// only see the one-shot completion inbox and miss everything after.
// Best-effort: log on failure but don't block the inbox notification.
if err := s.Queries.AddIssueSubscriber(ctx, db.AddIssueSubscriberParams{
IssueID: issue.ID,
UserType: "member",
UserID: requesterID,
Reason: "creator",
}); err != nil {
slog.Warn("quick-create completion: subscribe requester failed",
"task_id", util.UUIDToString(task.ID),
"issue_id", util.UUIDToString(issue.ID),
"requester_id", qc.RequesterID,
"error", err,
)
} else {
s.Bus.Publish(events.Event{
Type: protocol.EventSubscriberAdded,
WorkspaceID: qc.WorkspaceID,
ActorType: "agent",
ActorID: util.UUIDToString(task.AgentID),
Payload: map[string]any{
"issue_id": util.UUIDToString(issue.ID),
"user_type": "member",
"user_id": qc.RequesterID,
"reason": "creator",
},
})
}
prefix := s.getIssuePrefix(workspaceID)
identifier := fmt.Sprintf("%s-%d", prefix, issue.Number)
details, _ := json.Marshal(map[string]any{