diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c959524e32..7b405b41446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, coderpush-deploy] pull_request: - branches: [main] + branches: [main, coderpush-deploy] schedule: - cron: "23 3 * * *" workflow_dispatch: diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 1354cad2dc1..117f283407a 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -1875,4 +1875,3 @@ function readPathFromInput(input: Record | undefined): string | const path = input.file_path ?? input.path; return typeof path === "string" ? path : undefined; } - diff --git a/packages/views/projects/components/project-resources-section.tsx b/packages/views/projects/components/project-resources-section.tsx index 1c10c80a2b6..f6699be1bd5 100644 --- a/packages/views/projects/components/project-resources-section.tsx +++ b/packages/views/projects/components/project-resources-section.tsx @@ -704,7 +704,7 @@ function ResourceRow({ takes that slot, and this panel is narrow enough that an inline badge truncates the repository name away — which is exactly when someone needs to read both. - + Rendered even when nothing is pinned, showing "Default branch". Without it, clearing a branch makes the line vanish, which reads the same as the setting never having existed — there is no way to diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index b8ce727c51b..f1468b2f34a 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -638,8 +638,7 @@ func buildChatPrompt(task Task) string { // thread reader, so they get the transcript command without the thread // drill-down (MUL-4899). // - // WHERE the conversation lives is therefore per-branch, not shared: only the - // unconditional "don't go looking in issues/comments" survives up top. Saying + // WHERE the conversation lives is therefore per-branch, not shared. Saying // "its history lives in the channel, NOT in Multica" for every channel type // contradicted the very next line on a transcript surface, which tells the // agent Multica stored it and hands it the command to read it back. An agent @@ -652,7 +651,8 @@ func buildChatPrompt(task Task) string { // silently dropped it for Feishu/Lark (GH #6006). if task.ChatChannelType != "" { platform := channelDisplayName(task.ChatChannelType) - fmt.Fprintf(&b, "You are operating inside a %s conversation — not the Multica web app. Never look in Multica issues or comments for this conversation.\n", platform) + fmt.Fprintf(&b, "You are operating inside a %s conversation — not the Multica web app. Do not use Multica issues or comments as a substitute for this conversation's history.\n", platform) + b.WriteString("When the user asks about a referenced Multica issue, read that issue and its relevant comments with the Multica CLI within your existing role and access. A missing quote or unavailable channel history does not prevent looking up a supplied issue ID or link. This does not authorize unrelated issue access, new permissions, or actions beyond your Agent Identity.\n") if task.ChatChannelType == execenv.ChannelTypeSlack { fmt.Fprintf(&b, "This conversation and its history live in %s, NOT in Multica. The message below may be only what triggered you. Read the conversation with:\n", platform) b.WriteString("- `multica chat history --output json` — the channel overview: recent top-level messages, each thread tagged with a `thread_id` and `reply_count`. It does NOT expand thread contents.\n") diff --git a/server/internal/daemon/prompt_test.go b/server/internal/daemon/prompt_test.go index 9e13f0a8e72..36110484c52 100644 --- a/server/internal/daemon/prompt_test.go +++ b/server/internal/daemon/prompt_test.go @@ -580,10 +580,37 @@ func TestBuildChatPromptChannelAwareness(t *testing.T) { if strings.Contains(out, "NOT in Multica") { t.Errorf("transcript surface told its history is NOT in Multica, then told to read it from Multica\n--- output ---\n%s", out) } - // The useful half of the original sentence must survive: the agent - // still must not go hunting through issues and comments. - if !strings.Contains(out, "Never look in Multica issues or comments") { - t.Errorf("lost the issues/comments prohibition\n--- output ---\n%s", out) + if !strings.Contains(out, "Do not use Multica issues or comments as a substitute for this conversation's history") { + t.Errorf("lost the conversation-history boundary\n--- output ---\n%s", out) + } + }) + } +} + +func TestBuildChatPromptReferencedIssueLookup(t *testing.T) { + for _, channelType := range []string{ + execenv.ChannelTypeSlack, + execenv.ChannelTypeFeishu, + execenv.ChannelTypeWecom, + execenv.ChannelTypeDingtalk, + "unknown-channel", + } { + t.Run(channelType, func(t *testing.T) { + out := buildChatPrompt(Task{ + ChatSessionID: "sess-1", ChatChannelType: channelType, + ChatMessage: "[Recent context unavailable]\nCLP-104: what is the next step?", + }) + for _, want := range []string{ + "read that issue and its relevant comments with the Multica CLI within your existing role and access", + "A missing quote or unavailable channel history does not prevent looking up a supplied issue ID or link", + "This does not authorize unrelated issue access, new permissions, or actions beyond your Agent Identity", + } { + if !strings.Contains(out, want) { + t.Errorf("missing referenced-issue guidance %q", want) + } + } + if strings.Contains(out, "Never look in Multica issues or comments") { + t.Error("channel prompt still prohibits referenced-issue lookup") } }) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 89539197a2e..6cc243400a5 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -3335,6 +3335,31 @@ func (h *Handler) resolveMentionedAgentCommentTriggers(ctx context.Context, issu blockTarget("agent", m.ID, ReasonTargetUnavailable) continue } + // An agent coordinator must not start a second delivery run for the + // active assignee through another comment thread. Return a refusal, + // not "coalesced": the saved comment has not been delivered to that run. + // Human messages and mentions of other specialists retain their routing. + if authorType == "agent" && authorID != uuidToString(agent.ID) && issue.AssigneeType.String == "agent" && issue.AssigneeID == agent.ID { + active, err := h.Queries.HasActiveTaskForIssueAndAgent(ctx, db.HasActiveTaskForIssueAndAgentParams{IssueID: issue.ID, AgentID: agent.ID}) + if err != nil { + blockTarget("agent", m.ID, ReasonInternalError) + continue + } + // Supplements in the existing thread keep their coalescing/replay + // contract. Only a separate agent-created handoff is refused. + inThread := false + if active && opts.ThreadCommentID.Valid { + inThread, err = h.hasActiveTaskForIssueAndAgent(ctx, issue.ID, agent.ID, opts.ThreadCommentID) + if err != nil { + blockTarget("agent", m.ID, ReasonInternalError) + continue + } + } + if active && !inThread { + blockTarget("agent", m.ID, ReasonAlreadyActive) + continue + } + } // One readiness verdict for every admission path (service.AgentReadiness). // Only a BLOCKED verdict refuses the mention: an unbound agent has no // machine to bring back (MUL-5559), and a machine whose CLI cannot run diff --git a/server/internal/handler/comment_active_assignee_test.go b/server/internal/handler/comment_active_assignee_test.go new file mode 100644 index 00000000000..acbf071b363 --- /dev/null +++ b/server/internal/handler/comment_active_assignee_test.go @@ -0,0 +1,53 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + "github.com/multica-ai/multica/server/internal/testutil" +) + +func TestAgentMentionDoesNotRedispatchActiveAssignee(t *testing.T) { + ctx := context.Background() + for _, status := range []string{"queued", "dispatched", "running", "waiting_local_directory", "completed"} { + t.Run(status, func(t *testing.T) { + runtimeID := dbfx.Runtime(t, "handoff runtime") + agentID := dbfx.Agent(t, "executor", runtimeID) + coordinatorID := dbfx.Agent(t, "coordinator", runtimeID) + issueID := dbfx.Issue(t, "delivery", testutil.Cols{"assignee_type": "agent", "assignee_id": agentID}) + rootID := dbfx.Comment(t, issueID, "existing delivery handoff") + dbfx.Task(t, agentID, testutil.Cols{"runtime_id": runtimeID, "issue_id": issueID, "status": status, "trigger_comment_id": rootID, "comment_thread_id": rootID}) + issue, err := testHandler.Queries.GetIssue(ctx, parseUUID(issueID)) + if err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("[@Executor](mention://agent/%s) continue delivery", agentID) + triggers, targets := testHandler.computeCommentAgentTriggers(ctx, issue, content, nil, "agent", coordinatorID, commentTriggerComputeOptions{OriginatorUserID: testUserID}) + if status != "completed" { + if len(triggers) != 0 || len(targets) != 1 || targets[0].ReasonCode != ReasonAlreadyActive { + t.Fatalf("active assignee should be refused explicitly: triggers=%+v targets=%+v", triggers, targets) + } + } else if len(triggers) != 1 { + t.Fatalf("completed executor must accept a new handoff: %+v", targets) + } + root, err := testHandler.Queries.GetComment(ctx, parseUUID(rootID)) + if err != nil { + t.Fatal(err) + } + triggers, _ = testHandler.computeCommentAgentTriggers(ctx, issue, content, &root, "agent", coordinatorID, commentTriggerComputeOptions{OriginatorUserID: testUserID}) + if len(triggers) != 1 { + t.Fatal("supplements in the active thread must retain normal routing") + } + triggers, _ = testHandler.computeCommentAgentTriggers(ctx, issue, content, nil, "member", testUserID, commentTriggerComputeOptions{OriginatorUserID: testUserID}) + if len(triggers) != 1 { + t.Fatal("human follow-up must retain normal routing") + } + issue.AssigneeID = parseUUID(coordinatorID) + triggers, _ = testHandler.computeCommentAgentTriggers(ctx, issue, content, nil, "agent", coordinatorID, commentTriggerComputeOptions{OriginatorUserID: testUserID}) + if len(triggers) != 1 { + t.Fatal("mention of a separate specialist must retain normal routing") + } + }) + } +} diff --git a/server/internal/service/issue_wakeup_system.go b/server/internal/service/issue_wakeup_system.go index 6a4df182c67..b5714cbda25 100644 --- a/server/internal/service/issue_wakeup_system.go +++ b/server/internal/service/issue_wakeup_system.go @@ -49,7 +49,7 @@ var childDoneCondition = json.RawMessage(`{"type":"children_done","each_stage":t const ChildDoneDefaultInstruction = "Sub-issues of this issue have closed; the trigger facts list each stage and which one is next. " + "If a later stage is waiting, check that its dependencies are met, then move its sub-issues out of backlog so they start. " + "If a sub-issue in the closed stages was cancelled rather than finished, decide whether its work is still needed before advancing. " + - "When every sub-issue is closed, bring their results together on this issue and move it forward, or mark it ready for review when nothing remains." + "When every sub-issue is closed, continue this issue from the latest result. Reuse existing delivery and review issues; create a next stage only for distinct remaining work. If acceptance and required approvals are satisfied, mark this issue done; use in_review only for a required human decision and name its owner. Otherwise keep it in_progress or record the specific blocker. Check active runs before any handoff; do not add a second mention or rerun for an assignee already working." // SystemWakeupDefault is the rule's workspace default: whether it is on and // the instruction set for the workspace ("" when none).