mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a6970731 | ||
|
|
366f6e2283 | ||
|
|
548fad4f7b |
@@ -1,6 +1,9 @@
|
||||
export type CommentType = "comment" | "status_change" | "progress_update" | "system";
|
||||
|
||||
export type CommentAuthorType = "member" | "agent";
|
||||
// `system` is used by platform-generated rows (e.g. the parent-issue
|
||||
// child-done notification, MUL-2538). System rows carry a zero UUID for
|
||||
// author_id; render paths should branch on author_type rather than the UUID.
|
||||
export type CommentAuthorType = "member" | "agent" | "system";
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
|
||||
@@ -755,20 +755,34 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) {
|
||||
// The comment payload can come as handler.CommentResponse from the
|
||||
// HTTP handler, or as map[string]any from the agent comment path in
|
||||
// task.go. Handle both.
|
||||
var issueID, commentID, commentContent string
|
||||
var issueID, commentID, commentContent, authorType string
|
||||
switch c := payload["comment"].(type) {
|
||||
case handler.CommentResponse:
|
||||
issueID = c.IssueID
|
||||
commentID = c.ID
|
||||
commentContent = c.Content
|
||||
authorType = c.AuthorType
|
||||
case map[string]any:
|
||||
issueID, _ = c["issue_id"].(string)
|
||||
commentID, _ = c["id"].(string)
|
||||
commentContent, _ = c["content"].(string)
|
||||
authorType, _ = c["author_type"].(string)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Platform-authored system comments (MUL-2538 child-done parent
|
||||
// notify) must NOT create inbox rows or parse mentions from their
|
||||
// body — the comment is a controlled platform signal, not a human
|
||||
// commenter. Mention parsing is the dangerous bit: if the body
|
||||
// transcluded a child title containing `mention://member/<uuid>`,
|
||||
// the parent's assignee inbox would light up via the generic path.
|
||||
// Skip the listener entirely; the WS broadcast still delivers the
|
||||
// comment to the issue timeline.
|
||||
if authorType == "system" {
|
||||
return
|
||||
}
|
||||
|
||||
issueTitle, _ := payload["issue_title"].(string)
|
||||
issueStatus, _ := payload["issue_status"].(string)
|
||||
|
||||
|
||||
@@ -388,6 +388,105 @@ func TestNotification_CommentCreated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotification_SystemCommentSkipsInboxAndMentions guards the MUL-2538
|
||||
// must-fix: a comment with author_type='system' (the platform-generated
|
||||
// child-done parent notify) must NOT create any inbox rows for parent
|
||||
// subscribers and must NOT spawn mention-inbox rows even if the body string
|
||||
// contains markdown mentions. The reviewer's concern was that a child title
|
||||
// containing `mention://member/<uuid>` would silently light up that member's
|
||||
// inbox once the title was transcluded into the system comment body —
|
||||
// because the generic comment:created listener treated all comments
|
||||
// identically. The fix is to gate at author_type='system'.
|
||||
func TestNotification_SystemCommentSkipsInboxAndMentions(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := newNotificationBus(t, queries)
|
||||
|
||||
// Subscriber on the issue who would normally receive new_comment.
|
||||
subEmail := "notif-system-comment-sub@multica.ai"
|
||||
subID := createTestUser(t, subEmail)
|
||||
t.Cleanup(func() { cleanupTestUser(t, subEmail) })
|
||||
|
||||
// A second member whose UUID we will smuggle into the system-comment
|
||||
// body as a fake mention to prove the listener does not parse it.
|
||||
targetEmail := "notif-system-comment-target@multica.ai"
|
||||
targetID := createTestUser(t, targetEmail)
|
||||
t.Cleanup(func() { cleanupTestUser(t, targetEmail) })
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupInboxForIssue(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
addTestSubscriber(t, issueID, "member", subID, "manual")
|
||||
|
||||
// Publish a system-authored comment that transcludes a member mention
|
||||
// in the body — the exact attack vector the reviewer flagged. If the
|
||||
// generic listener path runs, the new_comment row will fire for `sub`
|
||||
// and the mention path will fire for `target`.
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventCommentCreated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "system",
|
||||
ActorID: "",
|
||||
Payload: map[string]any{
|
||||
"comment": handler.CommentResponse{
|
||||
ID: "00000000-0000-0000-0000-000000000000",
|
||||
IssueID: issueID,
|
||||
AuthorType: "system",
|
||||
AuthorID: "00000000-0000-0000-0000-000000000000",
|
||||
Content: "Sub-issue done — see [@Target](mention://member/" + targetID + ").",
|
||||
Type: "system",
|
||||
},
|
||||
"issue_title": "system comment isolation",
|
||||
"issue_status": "in_progress",
|
||||
},
|
||||
})
|
||||
|
||||
if items := inboxItemsForRecipient(t, queries, subID); len(items) != 0 {
|
||||
t.Errorf("expected 0 inbox rows for issue subscriber, got %d", len(items))
|
||||
}
|
||||
if items := inboxItemsForRecipient(t, queries, targetID); len(items) != 0 {
|
||||
t.Errorf("expected 0 inbox rows for smuggled @mention target, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscriberSystemCommentDoesNotSubscribe guards the same boundary on
|
||||
// the subscriber listener: a system-authored comment must NOT be treated as
|
||||
// "a commenter joined the conversation." The CHECK constraint on
|
||||
// issue_subscriber.user_type only permits ('member','agent'); without the
|
||||
// author_type='system' early-return, AddIssueSubscriber would log a noisy
|
||||
// constraint violation on every child-done event.
|
||||
func TestSubscriberSystemCommentDoesNotSubscribe(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerSubscriberListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() { cleanupTestIssue(t, issueID) })
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventCommentCreated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "system",
|
||||
ActorID: "",
|
||||
Payload: map[string]any{
|
||||
"comment": handler.CommentResponse{
|
||||
ID: "00000000-0000-0000-0000-000000000000",
|
||||
IssueID: issueID,
|
||||
AuthorType: "system",
|
||||
AuthorID: "00000000-0000-0000-0000-000000000000",
|
||||
Content: "platform notify",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if count := subscriberCount(t, queries, issueID); count != 0 {
|
||||
t.Fatalf("expected 0 subscribers after system comment, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotification_AssigneeChanged verifies the full assignee change flow:
|
||||
// - New assignee gets "issue_assigned" (Direct)
|
||||
// - Old assignee gets "unassigned" (Direct)
|
||||
|
||||
@@ -105,6 +105,16 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
|
||||
return
|
||||
}
|
||||
|
||||
// Platform-authored system comments (MUL-2538 child-done parent notify)
|
||||
// have author_type='system' and a zero UUID author. They must NOT
|
||||
// add a subscriber row: issue_subscriber.user_type is constrained to
|
||||
// ('member','agent'), and a "system" subscriber has no inbox to read
|
||||
// anyway. Skip them at the side-effect boundary so the system event
|
||||
// stays a pure WS broadcast for the timeline.
|
||||
if authorType == "system" {
|
||||
return
|
||||
}
|
||||
|
||||
addSubscriber(bus, queries, e.WorkspaceID, issueID, authorType, authorID, "commenter")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -366,19 +366,16 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
fmt.Fprintf(&b, "9. If blocked, run `multica issue status %s blocked` and post a comment explaining why\n\n", ctx.IssueID)
|
||||
}
|
||||
|
||||
// Parent / Sub-issue Protocol — best-effort convention, not a server-side
|
||||
// state sync. Skipped for chat, quick-create, and run-only autopilot runs
|
||||
// which have no parent/child semantics. Unified for both assignment- and
|
||||
// comment-triggered runs (Bohan's direction on PR #2918): describe the
|
||||
// mechanism, not a state machine. Comment-triggered runs naturally skip
|
||||
// the parent notification because the workflow above forbids unprompted
|
||||
// status flips — so a comment-triggered agent isn't "finishing" the
|
||||
// child and has nothing to report up.
|
||||
// Sub-issue creation semantics — the only piece of the old Parent /
|
||||
// Sub-issue Protocol (PR #2918) that still belongs in the brief. The
|
||||
// parent-notification guidance was dropped in MUL-2538: the platform
|
||||
// now posts a system comment on the parent itself when a child enters
|
||||
// `done`, and the agent has nothing to do or avoid on that path.
|
||||
// Section is skipped for chat, quick-create, and run-only autopilot
|
||||
// runs (no parent/child semantics there).
|
||||
if ctx.IssueID != "" && ctx.ChatSessionID == "" && ctx.QuickCreatePrompt == "" && ctx.AutopilotRunID == "" {
|
||||
b.WriteString("## Parent / Sub-issue Protocol\n\n")
|
||||
b.WriteString("Multica issues form a parent/child tree via `parent_issue_id`. The platform does NOT auto-sync child status to the parent — if a child finishes, its agent reports up. This is a best-effort convention.\n\n")
|
||||
b.WriteString("1. **Tell the parent when you finish a child.** If this issue has a `parent_issue_id` and you are wrapping it up (final-results comment posted and status flipped per the workflow above), also post one **top-level** comment on the parent (`multica issue comment add <parent-id>` with NO `--parent`): link the child as `[MUL-<num>](mention://issue/<child-id>)`, give its current status and a one-line outcome, and `@mention` the parent's assignee using the URL that matches `assignee_type` — `mention://agent/<id>`, `mention://member/<id>`, or `mention://squad/<id>`. Skip the mention if there is no assignee. If you are NOT changing this issue's status this run (e.g. a comment-triggered run that's just answering a question), you are not closing out the child — skip the parent notification.\n")
|
||||
b.WriteString("2. **Choosing `--status` when creating sub-issues.** `--status todo` = **start now** (the default — an agent assignee fires immediately). `--status backlog` = **wait** (assignee is set but no trigger fires; promote later with `multica issue status <child-id> todo`). Parallel children: all `--status todo`. Strict serial Step 1→2→3: only Step 1 is `todo`; Steps 2/3 are `--status backlog` from the start, promoted in turn.\n\n")
|
||||
b.WriteString("## Sub-issue Creation\n\n")
|
||||
b.WriteString("**Choosing `--status` when creating sub-issues.** `--status todo` = **start now** (the default — an agent assignee fires immediately). `--status backlog` = **wait** (assignee is set but no trigger fires; promote later with `multica issue status <child-id> todo`). Parallel children: all `--status todo`. Strict serial Step 1→2→3: only Step 1 is `todo`; Steps 2/3 are `--status backlog` from the start, promoted in turn.\n\n")
|
||||
}
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
|
||||
@@ -5,16 +5,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Parent/Sub-issue Protocol — the brief teaches every issue-bound agent two
|
||||
// things: when finishing a child issue, tell the parent; and when creating
|
||||
// sub-issues, pick `--status todo` (start now) vs `--status backlog` (wait)
|
||||
// deliberately. The protocol is runtime-only (no server-side state sync) and
|
||||
// the section is identical for assignment- and comment-triggered runs — the
|
||||
// comment-triggered workflow rule "Do NOT change the issue status unless the
|
||||
// comment explicitly asks for it" naturally short-circuits the parent
|
||||
// notification, so the protocol stays a single description.
|
||||
// Sub-issue Creation section — after MUL-2538 the platform posts the
|
||||
// child-done parent notification itself, so the brief no longer carries
|
||||
// any parent-notification rule (per Bohan's call on PR #3055: delete the
|
||||
// guidance entirely, do not replace it with a "do not post one" sentence
|
||||
// — the agent should not be thinking about parent comments at all). All
|
||||
// that remains is the `--status todo` vs `--status backlog` rule for
|
||||
// creating sub-issues, which is unrelated to the notification path.
|
||||
|
||||
func TestParentSubIssueProtocolPresentForIssueRuns(t *testing.T) {
|
||||
func TestSubIssueCreationSectionPresentForIssueRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -38,31 +37,10 @@ func TestParentSubIssueProtocolPresentForIssueRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := buildMetaSkillContent("claude", tc.ctx)
|
||||
|
||||
if !strings.Contains(out, "## Parent / Sub-issue Protocol") {
|
||||
t.Fatalf("expected Parent / Sub-issue Protocol section in %s brief", tc.name)
|
||||
if !strings.Contains(out, "## Sub-issue Creation") {
|
||||
t.Fatalf("expected Sub-issue Creation section in %s brief", tc.name)
|
||||
}
|
||||
for _, want := range []string{
|
||||
// Mechanism framing — describe the data model, not a state
|
||||
// machine. The same text must apply to every issue-bound run.
|
||||
"parent/child tree via `parent_issue_id`",
|
||||
"does NOT auto-sync",
|
||||
"best-effort",
|
||||
// Rule 1 — finish a child, tell the parent.
|
||||
"**Tell the parent when you finish a child.**",
|
||||
"`parent_issue_id`",
|
||||
"top-level",
|
||||
"NO `--parent`",
|
||||
"`@mention` the parent's assignee",
|
||||
"`mention://agent/<id>`",
|
||||
"`mention://member/<id>`",
|
||||
"`mention://squad/<id>`",
|
||||
"no assignee",
|
||||
// The comment-triggered escape hatch must live in the same
|
||||
// unified paragraph so both runs read it.
|
||||
"NOT changing this issue's status",
|
||||
"not closing out the child",
|
||||
"skip the parent notification",
|
||||
// Rule 2 — sub-issue creation semantics.
|
||||
"**Choosing `--status` when creating sub-issues.**",
|
||||
"`--status todo` = **start now**",
|
||||
"`--status backlog` = **wait**",
|
||||
@@ -71,72 +49,92 @@ func TestParentSubIssueProtocolPresentForIssueRuns(t *testing.T) {
|
||||
"`--status backlog` from the start",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("[%s] protocol missing %q", tc.name, want)
|
||||
}
|
||||
}
|
||||
// Earlier revisions split Step A by trigger type, used per-rule
|
||||
// gating tables, or ### A/### B subheadings. The unified
|
||||
// revision must not regress into any of those.
|
||||
for _, banned := range []string{
|
||||
"| Parent assignee | Parent status |",
|
||||
"The same agent as yourself",
|
||||
"| Member or squad |",
|
||||
"### A. Notify the parent",
|
||||
"### B. Choose",
|
||||
"When this issue has `parent_issue_id`:",
|
||||
"**Closing out child work** (only if this issue has `parent_issue_id`)",
|
||||
"**Notify the parent** (only if this issue has `parent_issue_id`",
|
||||
"**Creating sub-issues** (applies to any issue-bound run)",
|
||||
"For parent/child work, use these best-effort rules",
|
||||
// The protocol must no longer emit a placeholder
|
||||
// `<this-issue-id>` status flip — the workflow above owns
|
||||
// that command with the real issue id substituted.
|
||||
"`multica issue status <this-issue-id> in_review`",
|
||||
} {
|
||||
if strings.Contains(out, banned) {
|
||||
t.Errorf("[%s] expected %q to be removed", tc.name, banned)
|
||||
t.Errorf("[%s] section missing %q", tc.name, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lock in the "compact convention, not a spec" framing: the Parent /
|
||||
// Sub-issue Protocol section must stay short. The unified two-rule revision
|
||||
// runs around 6 lines; this guard prevents future edits from silently
|
||||
// re-inflating it back into a state-machine.
|
||||
func TestParentSubIssueProtocolIsCompact(t *testing.T) {
|
||||
// The brief must no longer carry any parent-notification guidance. PR
|
||||
// #2918 added a "Tell the parent when you finish a child" rule that
|
||||
// turned into noise (self-mention loops, planner ack ping-pong,
|
||||
// hardcoded `MUL-` prefix). PR #3055 first downgraded it to a "do NOT
|
||||
// post one" guardrail, but Bohan's product call was to remove the
|
||||
// guidance entirely rather than substitute a new prohibition. These
|
||||
// canaries lock that in: any wording that re-introduces the
|
||||
// parent-comment concept — positive, negative, or descriptive — must
|
||||
// not come back through future edits.
|
||||
func TestBriefHasNoParentNotificationGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "12345678-1234-1234-1234-123456789012",
|
||||
cases := []TaskContextForEnv{
|
||||
{IssueID: "11111111-2222-3333-4444-555555555555"},
|
||||
{
|
||||
IssueID: "22222222-3333-4444-5555-666666666666",
|
||||
TriggerCommentID: "33333333-4444-5555-6666-777777777777",
|
||||
},
|
||||
}
|
||||
out := buildMetaSkillContent("claude", ctx)
|
||||
for _, ctx := range cases {
|
||||
ctx := ctx
|
||||
out := buildMetaSkillContent("claude", ctx)
|
||||
|
||||
const header = "## Parent / Sub-issue Protocol"
|
||||
start := strings.Index(out, header)
|
||||
if start == -1 {
|
||||
t.Fatalf("protocol section missing")
|
||||
}
|
||||
rest := out[start+len(header):]
|
||||
end := strings.Index(rest, "\n## ")
|
||||
var section string
|
||||
if end == -1 {
|
||||
section = out[start:]
|
||||
} else {
|
||||
section = out[start : start+len(header)+end]
|
||||
}
|
||||
if got := strings.Count(section, "\n"); got > 10 {
|
||||
t.Errorf("Parent / Sub-issue Protocol should stay ≤10 lines (best-effort convention, not a spec); got %d:\n%s", got, section)
|
||||
// The pre-MUL-2538 phrasing instructed the agent to compose a
|
||||
// parent comment by hand — including a hardcoded `MUL-` prefix
|
||||
// and an assignee mention. The intermediate revision (PR #3055
|
||||
// before Bohan's call) instead told the agent NOT to post one.
|
||||
// Both framings must stay out.
|
||||
for _, banned := range []string{
|
||||
// Old "do it yourself" framing (PR #2918).
|
||||
"## Parent / Sub-issue Protocol",
|
||||
"**Tell the parent when you finish a child.**",
|
||||
"multica issue comment add <parent-id>",
|
||||
"with NO `--parent`",
|
||||
"link the child as `[MUL-",
|
||||
"`@mention` the parent's assignee",
|
||||
"`mention://agent/<id>`",
|
||||
"`mention://member/<id>`",
|
||||
"`mention://squad/<id>`",
|
||||
// Intermediate "do NOT do it yourself" framing (PR #3055
|
||||
// before Bohan's call) — also out per product direction.
|
||||
"**Do NOT post your own parent-notification comment.**",
|
||||
"Do NOT post your own parent-notification comment",
|
||||
"parent-notification comment",
|
||||
"system comment on the parent fires from the status transition",
|
||||
"re-trigger the parent's assignee for nothing",
|
||||
"platform posts a top-level system comment on the parent",
|
||||
// Earlier revisions split rules by trigger type or used
|
||||
// table/subsection layouts. None of those structures should
|
||||
// come back either.
|
||||
"| Parent assignee | Parent status |",
|
||||
"The same agent as yourself",
|
||||
"| Member or squad |",
|
||||
"### A. Notify the parent",
|
||||
"### B. Choose",
|
||||
"When this issue has `parent_issue_id`:",
|
||||
"**Closing out child work** (only if this issue has `parent_issue_id`)",
|
||||
"**Notify the parent** (only if this issue has `parent_issue_id`",
|
||||
"**Creating sub-issues** (applies to any issue-bound run)",
|
||||
"For parent/child work, use these best-effort rules",
|
||||
// The protocol must no longer emit a placeholder
|
||||
// `<this-issue-id>` status flip — the workflow above owns
|
||||
// that command with the real issue id substituted.
|
||||
"`multica issue status <this-issue-id> in_review`",
|
||||
// Non-existent CLI form Elon's earlier review flagged.
|
||||
"issue list --parent",
|
||||
} {
|
||||
if strings.Contains(out, banned) {
|
||||
t.Errorf("expected %q to be removed from the brief", banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comment-triggered briefs must NOT carry any unconditional status-flip
|
||||
// command targeting the current issue. The previous revision had a
|
||||
// dedicated Step A that wrote `multica issue status <this-issue-id> in_review`
|
||||
// into the protocol; the unified revision removes that command from the
|
||||
// protocol entirely and leans on the comment-triggered workflow rule
|
||||
// "Do NOT change the issue status unless the comment explicitly asks for it"
|
||||
// to keep the agent honest (Elon's blocking review on PR #2918).
|
||||
// command targeting the current issue. Previous revisions had a
|
||||
// dedicated protocol step that wrote `multica issue status <this-issue-id> in_review`;
|
||||
// the comment-triggered workflow rule "Do NOT change the issue status
|
||||
// unless the comment explicitly asks for it" must remain the source of
|
||||
// truth (Elon's blocking review on PR #2918).
|
||||
func TestCommentTriggeredProtocolDoesNotForceInReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := TaskContextForEnv{
|
||||
@@ -145,40 +143,20 @@ func TestCommentTriggeredProtocolDoesNotForceInReview(t *testing.T) {
|
||||
}
|
||||
out := buildMetaSkillContent("claude", ctx)
|
||||
|
||||
// The placeholder `<this-issue-id>` only ever lived inside the protocol
|
||||
// section; the workflow above substitutes the real id. So the literal
|
||||
// substring is the right canary for "protocol is trying to flip status
|
||||
// behind the workflow's back".
|
||||
if strings.Contains(out, "`multica issue status <this-issue-id> in_review`") {
|
||||
t.Errorf("comment-triggered brief must not contain a placeholder `<this-issue-id> in_review` flip — that conflicts with the comment-triggered \"do not change status unless asked\" rule")
|
||||
}
|
||||
|
||||
// The comment-triggered workflow guardrail must still be present so the
|
||||
// protocol's unified instruction has something to defer to.
|
||||
const guardrail = "Do NOT change the issue status unless the comment explicitly asks for it"
|
||||
if !strings.Contains(out, guardrail) {
|
||||
t.Errorf("expected the comment-triggered workflow guardrail %q to be present", guardrail)
|
||||
}
|
||||
|
||||
// The unified protocol paragraph must still teach the agent that a
|
||||
// comment-triggered run without a status flip means "not closing out
|
||||
// the child" → skip the parent notification.
|
||||
for _, want := range []string{
|
||||
"NOT changing this issue's status",
|
||||
"not closing out the child",
|
||||
"skip the parent notification",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("comment-triggered protocol missing required short-circuit phrasing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assignment-triggered briefs are the inverse boundary: when the agent owns
|
||||
// the issue lifecycle, the brief AS A WHOLE must still tell it to flip to
|
||||
// in_review on completion. After unification the flip lives in the
|
||||
// assignment-triggered workflow above (with the real id substituted), not
|
||||
// in the protocol section, so we assert against the actual id.
|
||||
// Assignment-triggered briefs are the inverse boundary: when the agent
|
||||
// owns the issue lifecycle, the brief AS A WHOLE must still tell it to
|
||||
// flip to in_review on completion. The flip lives in the
|
||||
// assignment-triggered workflow above (with the real id substituted).
|
||||
func TestAssignmentTriggeredProtocolStillFlipsInReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
const issueID = "77777777-8888-9999-aaaa-bbbbbbbbbbbb"
|
||||
@@ -191,21 +169,21 @@ func TestAssignmentTriggeredProtocolStillFlipsInReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2 (creating sub-issues) must apply to any issue-bound run, including
|
||||
// a top-level parent issue that has no `parent_issue_id` of its own. The
|
||||
// unified preamble must not globally gate the protocol on the current issue
|
||||
// being a child, and rule 2 must not carry any `parent_issue_id` reference.
|
||||
func TestSubIssueCreationRuleIsUnconditional(t *testing.T) {
|
||||
// The sub-issue creation rule must reach top-level parents that have no
|
||||
// `parent_issue_id` of their own — that is where the `todo` vs `backlog`
|
||||
// decision matters most. The section must not gate on this issue being
|
||||
// a child, and must not even mention `parent_issue_id`.
|
||||
func TestSubIssueCreationSectionIsUnconditional(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
}
|
||||
out := buildMetaSkillContent("claude", ctx)
|
||||
|
||||
const header = "## Parent / Sub-issue Protocol"
|
||||
const header = "## Sub-issue Creation"
|
||||
start := strings.Index(out, header)
|
||||
if start == -1 {
|
||||
t.Fatalf("protocol section missing")
|
||||
t.Fatalf("sub-issue creation section missing")
|
||||
}
|
||||
rest := out[start:]
|
||||
end := strings.Index(rest[len(header):], "\n## ")
|
||||
@@ -216,36 +194,12 @@ func TestSubIssueCreationRuleIsUnconditional(t *testing.T) {
|
||||
section = rest[:len(header)+end]
|
||||
}
|
||||
|
||||
// Preamble must not globally gate on `parent_issue_id`.
|
||||
for _, banned := range []string{
|
||||
"When this issue has `parent_issue_id`:",
|
||||
"For parent/child work, use these best-effort rules",
|
||||
} {
|
||||
if strings.Contains(section, banned) {
|
||||
t.Errorf("preamble must not globally gate the protocol on `parent_issue_id` — rule 2 needs to reach top-level parents too; found %q", banned)
|
||||
}
|
||||
}
|
||||
|
||||
// Find rule 2 and check it does NOT reference `parent_issue_id` at all
|
||||
// (the only mention of `parent_issue_id` in the section belongs to
|
||||
// rule 1's "if this issue has a `parent_issue_id`" gate).
|
||||
rule2Idx := strings.Index(section, "2. **Choosing `--status` when creating sub-issues.**")
|
||||
if rule2Idx == -1 {
|
||||
t.Fatalf("rule 2 (Choosing `--status` when creating sub-issues) missing from protocol section")
|
||||
}
|
||||
rule2 := section[rule2Idx:]
|
||||
if strings.Contains(rule2, "parent_issue_id") {
|
||||
t.Errorf("rule 2 (Choosing `--status` when creating sub-issues) must not be gated by `parent_issue_id`; it applies to any issue-bound run:\n%s", rule2)
|
||||
}
|
||||
|
||||
// Rule 1 must still carry the gate — without it the agent might post on
|
||||
// a parent that doesn't exist.
|
||||
if !strings.Contains(section, "**Tell the parent when you finish a child.** If this issue has a `parent_issue_id`") {
|
||||
t.Errorf("rule 1 missing per-rule `parent_issue_id` gate")
|
||||
if strings.Contains(section, "parent_issue_id") {
|
||||
t.Errorf("Sub-issue Creation section must not reference `parent_issue_id` — it applies to any issue-bound run, including top-level parents:\n%s", section)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentSubIssueProtocolSkippedForNonIssueModes(t *testing.T) {
|
||||
func TestSubIssueCreationSectionSkippedForNonIssueModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -269,37 +223,9 @@ func TestParentSubIssueProtocolSkippedForNonIssueModes(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := buildMetaSkillContent("claude", tc.ctx)
|
||||
if strings.Contains(out, "## Parent / Sub-issue Protocol") {
|
||||
t.Errorf("%s mode must NOT emit the Parent / Sub-issue Protocol section", tc.name)
|
||||
if strings.Contains(out, "## Sub-issue Creation") {
|
||||
t.Errorf("%s mode must NOT emit the Sub-issue Creation section", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Guardrails for things Elon's review explicitly flagged: no reference to a
|
||||
// non-existent `multica issue list --parent` command, and no claim that the
|
||||
// protocol is a stable / guaranteed handshake.
|
||||
func TestParentSubIssueProtocolHasNoForbiddenClaims(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "44444444-5555-6666-7777-888888888888",
|
||||
}
|
||||
out := buildMetaSkillContent("claude", ctx)
|
||||
|
||||
for _, banned := range []string{
|
||||
"issue list --parent",
|
||||
"is a guaranteed handshake",
|
||||
"is a reliable handshake",
|
||||
"guarantees parent sync",
|
||||
"reliable parent sync",
|
||||
} {
|
||||
if strings.Contains(out, banned) {
|
||||
t.Errorf("brief must not contain %q (best-effort only, no inexistent CLI)", banned)
|
||||
}
|
||||
}
|
||||
// The brief must explicitly frame the signal as best-effort so the
|
||||
// agent does not assume the parent always sees it.
|
||||
if !strings.Contains(out, "best-effort") {
|
||||
t.Errorf("brief must explicitly call the parent notification best-effort")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,6 +1051,15 @@ func (h *Handler) advanceIssueToDone(ctx context.Context, issue db.Issue, worksp
|
||||
slog.Warn("github: advance issue to done failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Fire the platform parent-notification path on the same transition the
|
||||
// HTTP UpdateIssue / BatchUpdateIssues paths use. A merged PR is one of
|
||||
// the most common ways a sub-issue actually reaches `done`, and skipping
|
||||
// it here would leave the parent silent for the dominant completion path.
|
||||
// notifyParentOfChildDone re-checks every guard (prev != done, parent
|
||||
// exists, parent not terminal), so calling it unconditionally is safe.
|
||||
h.notifyParentOfChildDone(ctx, issue, updated)
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
||||
resp := issueToResponse(updated, prefix)
|
||||
h.publish(protocol.EventIssueUpdated, workspaceID, "system", "", map[string]any{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -1336,3 +1337,140 @@ func TestGitHubInstallationBroadcastRedaction(t *testing.T) {
|
||||
t.Errorf("installation_id leaked into broadcast JSON: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_MergedPR_ChildWithParent_NotifiesParent guards the MUL-2538
|
||||
// must-fix: a merged PR is the dominant path by which a sub-issue actually
|
||||
// reaches `done`, and that path goes through advanceIssueToDone — not the
|
||||
// HTTP UpdateIssue / BatchUpdateIssues handlers that originally wired up
|
||||
// notifyParentOfChildDone. Without the helper call inside advanceIssueToDone,
|
||||
// the parent receives nothing when a child is closed by merging its PR.
|
||||
// This test fires a `pull_request closed merged` webhook against a child
|
||||
// issue and verifies the parent gets exactly one platform-generated system
|
||||
// comment with the child's real workspace identifier.
|
||||
func TestWebhook_MergedPR_ChildWithParent_NotifiesParent(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
secret := "merge-parent-notify-secret"
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
|
||||
// Create parent (open) + child (in_progress) pair.
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "PR-merge parent " + time.Now().Format(time.RFC3339Nano),
|
||||
"status": "in_progress",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue parent: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parent IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&parent)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "PR-merge child " + time.Now().Format(time.RFC3339Nano),
|
||||
"status": "in_progress",
|
||||
"parent_issue_id": parent.ID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue child: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var child IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&child)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id IN ($1, $2)`, child.ID, parent.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id IN ($1, $2)`, child.ID, parent.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id IN ($1, $2)`, child.ID, parent.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, child.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, parent.ID)
|
||||
})
|
||||
|
||||
const installationID int64 = 88990011
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "merge-parent-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"action": "closed",
|
||||
"pull_request": map[string]any{
|
||||
"number": 4242,
|
||||
"html_url": "https://github.com/acme/widget/pull/4242",
|
||||
"title": "Fix " + child.Identifier,
|
||||
"body": "",
|
||||
"state": "closed",
|
||||
"draft": false,
|
||||
"merged": true,
|
||||
"merged_at": "2026-04-29T00:00:00Z",
|
||||
"closed_at": "2026-04-29T00:00:00Z",
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z",
|
||||
"head": map[string]any{"ref": "fix/child"},
|
||||
"user": map[string]any{"login": "octocat", "avatar_url": ""},
|
||||
},
|
||||
"repository": map[string]any{
|
||||
"name": "widget",
|
||||
"owner": map[string]any{"login": "acme"},
|
||||
},
|
||||
"installation": map[string]any{"id": installationID},
|
||||
})
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(body))
|
||||
req2.Header.Set("X-GitHub-Event", "pull_request")
|
||||
req2.Header.Set("X-Hub-Signature-256", sig)
|
||||
testHandler.HandleGitHubWebhook(w, req2)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("webhook: expected 202, got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Child must now be done (sanity check — the existing path).
|
||||
updatedChild, err := testHandler.Queries.GetIssue(ctx, parseUUID(child.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue child: %v", err)
|
||||
}
|
||||
if updatedChild.Status != "done" {
|
||||
t.Fatalf("expected child status 'done', got %q", updatedChild.Status)
|
||||
}
|
||||
|
||||
// Parent must have received exactly one platform-generated system comment.
|
||||
var sysCount int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM comment WHERE issue_id = $1 AND author_type = 'system'`,
|
||||
parent.ID,
|
||||
).Scan(&sysCount); err != nil {
|
||||
t.Fatalf("count system comments on parent: %v", err)
|
||||
}
|
||||
if sysCount != 1 {
|
||||
t.Fatalf("expected 1 system comment on parent after PR-merge auto-done, got %d", sysCount)
|
||||
}
|
||||
|
||||
var content string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT content FROM comment WHERE issue_id = $1 AND author_type = 'system' LIMIT 1`,
|
||||
parent.ID,
|
||||
).Scan(&content); err != nil {
|
||||
t.Fatalf("read system comment: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, child.Identifier) {
|
||||
t.Errorf("system comment should reference child identifier %q, got: %s", child.Identifier, content)
|
||||
}
|
||||
for _, banned := range []string{"mention://agent/", "mention://member/", "mention://squad/"} {
|
||||
if strings.Contains(content, banned) {
|
||||
t.Errorf("system comment must not include %q mention, got: %s", banned, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2259,6 +2259,15 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
}
|
||||
|
||||
// Platform-driven parent notification: when this issue transitions into
|
||||
// `done` and has a parent, post a top-level system comment on the parent
|
||||
// (MUL-2538 — replaces the agent-prompt rule that caused self-mention
|
||||
// loops in PR #2918). The helper guards on transition + parent state and
|
||||
// fails best-effort.
|
||||
if statusChanged {
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -2671,6 +2680,12 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
}
|
||||
|
||||
// Platform-driven parent notification, mirrored from UpdateIssue
|
||||
// (MUL-2538). Best-effort; failure does not abort the batch.
|
||||
if statusChanged {
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue)
|
||||
}
|
||||
|
||||
updated++
|
||||
}
|
||||
|
||||
|
||||
94
server/internal/handler/issue_child_done.go
Normal file
94
server/internal/handler/issue_child_done.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// notifyParentOfChildDone posts a top-level system comment on the parent
|
||||
// issue when a child issue transitions from non-done into done. This replaces
|
||||
// the agent-prompt rule that previously made child agents post the
|
||||
// notification themselves (PR #2918 user feedback — the agent rule caused
|
||||
// self-mention loops, planner ping-pong, and accidental `MUL-` prefix
|
||||
// hardcoding because the agent did not always know the workspace prefix).
|
||||
//
|
||||
// Guards:
|
||||
// - prev.Status must not already be "done" (idempotent — repeat saves of
|
||||
// done do not re-fire; only the transition fires)
|
||||
// - issue.Status must be "done"
|
||||
// - issue.ParentIssueID must be set
|
||||
// - parent must not be "done" or "cancelled" — the parent is already
|
||||
// closed and a notification has no follow-up to drive
|
||||
//
|
||||
// The comment is inserted directly via db.Queries (not through the
|
||||
// CreateComment HTTP handler) so it bypasses the assignee on_comment trigger
|
||||
// and the mention-based agent enqueue paths. The content carries no
|
||||
// `mention://agent/...` / `mention://member/...` / `mention://squad/...`
|
||||
// links — only the safe issue mention for the child identifier. This is the
|
||||
// "no default mention side-effect" requirement from MUL-2538.
|
||||
//
|
||||
// Errors are logged at warn level and swallowed: this is a best-effort
|
||||
// notification on the side of a successful status update; failing it must
|
||||
// not roll back the user's status change.
|
||||
func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Issue) {
|
||||
if !issue.ParentIssueID.Valid {
|
||||
return
|
||||
}
|
||||
if prev.Status == "done" || issue.Status != "done" {
|
||||
return
|
||||
}
|
||||
parent, err := h.Queries.GetIssue(ctx, issue.ParentIssueID)
|
||||
if err != nil {
|
||||
slog.Warn("child done: failed to load parent",
|
||||
"error", err,
|
||||
"child_id", uuidToString(issue.ID),
|
||||
"parent_id", uuidToString(issue.ParentIssueID))
|
||||
return
|
||||
}
|
||||
if parent.Status == "done" || parent.Status == "cancelled" {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
||||
identifier := prefix + "-" + strconv.Itoa(int(issue.Number))
|
||||
childID := uuidToString(issue.ID)
|
||||
title := issue.Title
|
||||
content := fmt.Sprintf(
|
||||
"Sub-issue [%s](mention://issue/%s) — \"%s\" — is done. Confirm whether to advance the next step on this parent (and promote any waiting `backlog` sub-issues).",
|
||||
identifier, childID, title,
|
||||
)
|
||||
|
||||
// author_type='system', author_id=zero UUID. The zero UUID is a valid 16
|
||||
// byte value and the column is NOT NULL; frontend code should branch on
|
||||
// author_type === 'system' rather than on the UUID value.
|
||||
comment, err := h.Queries.CreateComment(ctx, db.CreateCommentParams{
|
||||
IssueID: parent.ID,
|
||||
WorkspaceID: parent.WorkspaceID,
|
||||
AuthorType: "system",
|
||||
AuthorID: pgtype.UUID{Valid: true},
|
||||
Content: content,
|
||||
Type: "system",
|
||||
ParentID: pgtype.UUID{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("child done: create system comment failed",
|
||||
"error", err,
|
||||
"child_id", childID,
|
||||
"parent_id", uuidToString(parent.ID))
|
||||
return
|
||||
}
|
||||
|
||||
h.publish(protocol.EventCommentCreated, uuidToString(parent.WorkspaceID), "system", "", map[string]any{
|
||||
"comment": commentToResponse(comment, nil, nil),
|
||||
"issue_title": parent.Title,
|
||||
"issue_assignee_type": textToPtr(parent.AssigneeType),
|
||||
"issue_assignee_id": uuidToPtr(parent.AssigneeID),
|
||||
"issue_status": parent.Status,
|
||||
})
|
||||
}
|
||||
241
server/internal/handler/issue_child_done_test.go
Normal file
241
server/internal/handler/issue_child_done_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// childDoneFixture creates a parent + child pair so the parent-notification
|
||||
// tests can drive the child's status changes independently. Cleanup is
|
||||
// registered on the test so the rows are removed even on test failure.
|
||||
type childDoneFixture struct {
|
||||
parent IssueResponse
|
||||
child IssueResponse
|
||||
}
|
||||
|
||||
func newChildDoneFixture(t *testing.T, parentStatus string) childDoneFixture {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "child-done parent " + time.Now().Format(time.RFC3339Nano),
|
||||
"status": parentStatus,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parent IssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
|
||||
t.Fatalf("decode parent: %v", err)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "child-done child " + time.Now().Format(time.RFC3339Nano),
|
||||
"status": "in_progress",
|
||||
"parent_issue_id": parent.ID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create child: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var child IssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&child); err != nil {
|
||||
t.Fatalf("decode child: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
// Cascades through comment.
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, child.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, parent.ID)
|
||||
})
|
||||
|
||||
return childDoneFixture{parent: parent, child: child}
|
||||
}
|
||||
|
||||
// updateChildStatus drives an UpdateIssue HTTP call against the child issue.
|
||||
func updateChildStatus(t *testing.T, childID, status string) {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("PUT", "/api/issues/"+childID, map[string]any{"status": status})
|
||||
req = withURLParam(req, "id", childID)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue child status=%q: expected 200, got %d: %s", status, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// countSystemCommentsOn returns the number of platform-generated comments on
|
||||
// the given issue. The schema CHECK was widened in migration 107 to allow
|
||||
// author_type='system'; this query is the canary that the migration applied
|
||||
// and the helper inserts with the right author identity.
|
||||
func countSystemCommentsOn(t *testing.T, issueID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM comment WHERE issue_id = $1 AND author_type = 'system'`,
|
||||
issueID,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("count system comments: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func systemCommentOn(t *testing.T, issueID string) (content, authorIDStr string, parentNull bool, typeStr string) {
|
||||
t.Helper()
|
||||
row := testPool.QueryRow(context.Background(),
|
||||
`SELECT content, author_id::text, parent_id IS NULL, type
|
||||
FROM comment
|
||||
WHERE issue_id = $1 AND author_type = 'system'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
issueID)
|
||||
if err := row.Scan(&content, &authorIDStr, &parentNull, &typeStr); err != nil {
|
||||
t.Fatalf("read system comment: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestChildDoneNotifiesParent — the happy path. A child transitioning from a
|
||||
// non-done status into `done` while its parent is open must produce exactly
|
||||
// one top-level platform-generated comment on the parent. The comment must
|
||||
// reference the child by its workspace-specific identifier (NOT a hardcoded
|
||||
// `MUL-` prefix — that was the bug PR #2918 review called out) and must not
|
||||
// carry an `@mention` link to any member/agent/squad (those would re-trigger
|
||||
// the parent's assignee, which is the noise this change removed).
|
||||
func TestChildDoneNotifiesParent(t *testing.T) {
|
||||
fx := newChildDoneFixture(t, "in_progress")
|
||||
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
|
||||
if got := countSystemCommentsOn(t, fx.parent.ID); got != 1 {
|
||||
t.Fatalf("expected exactly 1 system comment on parent, got %d", got)
|
||||
}
|
||||
content, authorID, parentNull, typeStr := systemCommentOn(t, fx.parent.ID)
|
||||
|
||||
if !parentNull {
|
||||
t.Errorf("system comment must be top-level (parent_id IS NULL)")
|
||||
}
|
||||
if typeStr != "system" {
|
||||
t.Errorf("system comment type should be 'system', got %q", typeStr)
|
||||
}
|
||||
if authorID != "00000000-0000-0000-0000-000000000000" {
|
||||
t.Errorf("system comment author_id should be the zero UUID sentinel, got %q", authorID)
|
||||
}
|
||||
|
||||
// Identifier substring must use the real workspace prefix (HAN-, seeded
|
||||
// in TestMain), never MUL-.
|
||||
if !strings.Contains(content, fx.child.Identifier) {
|
||||
t.Errorf("expected comment to contain child identifier %q, got: %s", fx.child.Identifier, content)
|
||||
}
|
||||
if strings.Contains(content, "MUL-") {
|
||||
t.Errorf("comment must not hardcode MUL- prefix, got: %s", content)
|
||||
}
|
||||
|
||||
// The comment must contain the safe issue mention but NOT any
|
||||
// agent/member/squad mention (those would re-trigger the parent's
|
||||
// assignee).
|
||||
if !strings.Contains(content, "mention://issue/"+fx.child.ID) {
|
||||
t.Errorf("expected mention://issue/<child-id> link in comment, got: %s", content)
|
||||
}
|
||||
for _, banned := range []string{"mention://agent/", "mention://member/", "mention://squad/"} {
|
||||
if strings.Contains(content, banned) {
|
||||
t.Errorf("comment must not include %q mention (auto-mention side effect), got: %s", banned, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDoneNotificationIsIdempotent — re-saving an already-done child
|
||||
// must NOT fire a second notification. UpdateIssue is called with the same
|
||||
// status='done' twice; only the first call is a transition and should
|
||||
// produce a comment.
|
||||
func TestChildDoneNotificationIsIdempotent(t *testing.T) {
|
||||
fx := newChildDoneFixture(t, "in_progress")
|
||||
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
if got := countSystemCommentsOn(t, fx.parent.ID); got != 1 {
|
||||
t.Fatalf("after first done: expected 1 comment, got %d", got)
|
||||
}
|
||||
|
||||
// Second save of done — should be a no-op transition.
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
if got := countSystemCommentsOn(t, fx.parent.ID); got != 1 {
|
||||
t.Fatalf("after second done: expected still 1 comment (idempotent), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildReopenAndDoneFiresAgain — done → in_progress → done IS a real
|
||||
// new completion event and should produce a second notification. This
|
||||
// captures the "reopen + done counts as a new event" line from MUL-2538.
|
||||
func TestChildReopenAndDoneFiresAgain(t *testing.T) {
|
||||
fx := newChildDoneFixture(t, "in_progress")
|
||||
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
updateChildStatus(t, fx.child.ID, "in_progress")
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
|
||||
if got := countSystemCommentsOn(t, fx.parent.ID); got != 2 {
|
||||
t.Fatalf("expected 2 system comments after reopen+done cycle, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDoneSkippedWhenParentDone — when the parent is already at a
|
||||
// terminal status, there is nothing for the parent assignee to advance to,
|
||||
// so the notification must NOT fire.
|
||||
func TestChildDoneSkippedWhenParentDone(t *testing.T) {
|
||||
fx := newChildDoneFixture(t, "done")
|
||||
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
|
||||
if got := countSystemCommentsOn(t, fx.parent.ID); got != 0 {
|
||||
t.Errorf("parent at 'done' should not receive notification, got %d comments", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDoneSkippedWhenParentCancelled — same as above for cancelled.
|
||||
func TestChildDoneSkippedWhenParentCancelled(t *testing.T) {
|
||||
fx := newChildDoneFixture(t, "cancelled")
|
||||
|
||||
updateChildStatus(t, fx.child.ID, "done")
|
||||
|
||||
if got := countSystemCommentsOn(t, fx.parent.ID); got != 0 {
|
||||
t.Errorf("parent at 'cancelled' should not receive notification, got %d comments", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDoneSkippedWhenNoParent — an issue with no parent_issue_id must
|
||||
// not produce any system comment on anything.
|
||||
func TestChildDoneSkippedWhenNoParent(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "orphan child-done " + time.Now().Format(time.RFC3339Nano),
|
||||
"status": "in_progress",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create orphan: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var orphan IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&orphan)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, orphan.ID)
|
||||
})
|
||||
|
||||
// Sanity baseline — there should be zero system comments anywhere in
|
||||
// the workspace attributable to this orphan transition. We can only
|
||||
// check that the orphan didn't somehow get one itself, but combined
|
||||
// with the no-parent code path returning early, that is sufficient.
|
||||
updateChildStatus(t, orphan.ID, "done")
|
||||
|
||||
if got := countSystemCommentsOn(t, orphan.ID); got != 0 {
|
||||
t.Errorf("orphan must not receive a self-notification, got %d system comments", got)
|
||||
}
|
||||
}
|
||||
6
server/migrations/107_comment_system_author.down.sql
Normal file
6
server/migrations/107_comment_system_author.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Drop any platform-generated rows before re-tightening the CHECK so the
|
||||
-- ADD CONSTRAINT does not fail on existing system comments.
|
||||
DELETE FROM comment WHERE author_type = 'system';
|
||||
ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_author_type_check;
|
||||
ALTER TABLE comment ADD CONSTRAINT comment_author_type_check
|
||||
CHECK (author_type IN ('member', 'agent'));
|
||||
8
server/migrations/107_comment_system_author.up.sql
Normal file
8
server/migrations/107_comment_system_author.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Allow platform-generated rows in the comment table. Used by the
|
||||
-- child-done parent-notification path (MUL-2538) so the platform can post a
|
||||
-- top-level comment on the parent issue without attributing it to a member
|
||||
-- or agent. system rows use a zero UUID for author_id (the column is still
|
||||
-- NOT NULL).
|
||||
ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_author_type_check;
|
||||
ALTER TABLE comment ADD CONSTRAINT comment_author_type_check
|
||||
CHECK (author_type IN ('member', 'agent', 'system'));
|
||||
Reference in New Issue
Block a user