mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
fix(quick-create): subscribe requester to issues created via quick-create (#1924)
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>
This commit is contained in:
146
server/cmd/server/quick_create_subscriber_test.go
Normal file
146
server/cmd/server/quick_create_subscriber_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user