Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
80a6970731 fix(runtime): drop parent-notification guidance from agent brief (MUL-2538)
Per Bohan's product call on PR #3055: the platform now owns the
child-done parent notification, so the runtime brief should not mention
the parent-comment path at all — not as an instruction, not as a "do
not do it" guardrail. The previous revision kept rule 1 of the Parent /
Sub-issue Protocol as a "Do NOT post your own parent-notification
comment." sentence; that still puts the concept in front of the agent
every run, which is exactly what we are trying to avoid.

What changes:
- Delete the "Parent / Sub-issue Protocol" preamble and rule 1 from
  buildMetaSkillContent. The remaining content — the `--status todo`
  vs `--status backlog` rule for creating sub-issues — now lives in a
  dedicated `## Sub-issue Creation` section, since the parent/child
  framing it previously sat under is gone.
- The system comment on the parent stays exactly as in 366f6e2: the
  agent simply does not need to know about it.

Tests:
- runtime_config_test.go is rewritten around the new section name and
  the wider "no parent-notification guidance" canary; the banned list
  now covers both the original PR #2918 wording and the intermediate
  "do NOT post one" wording.

System comment UI: the frontend already renders `author_type === "system"`
with author name "Multica" (`useActorName`) and the MulticaIcon avatar
(`ActorAvatar` via `isSystem`), matching Bohan's "looks like a normal
comment, author is multica + multica logo" requirement — no frontend
changes needed.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 14:43:51 +08:00
Jiang Bohan
366f6e2283 fix(issues): isolate system comments + wire GH merge path (MUL-2538)
Addresses the two must-fix items from the PR #3055 second review:

1. The platform-generated `comment:created` event (author_type='system')
   was running through the generic comment listeners, which (a) tried to
   subscribe the zero-UUID author and (b) parsed @mentions from the body
   for inbox notifications. Both subscriber_listeners and
   notification_listeners now early-return on author_type='system' so the
   event becomes a pure WS broadcast for the timeline — no inbox rows,
   no transcluded-mention attack surface.

2. advanceIssueToDone (the GitHub merge auto-done path) only published
   issue:updated and skipped notifyParentOfChildDone, so a child closed
   via merged PR — the dominant completion path — left the parent
   silent. The helper is now invoked on the same prev/updated pair, with
   the existing guards (transition + parent state) protecting double-fire.

Tests:
- New cmd/server/notification_listeners_test:
  TestNotification_SystemCommentSkipsInboxAndMentions (parent subscribers
  and smuggled @mention targets stay quiet),
  TestSubscriberSystemCommentDoesNotSubscribe (zero-UUID never reaches
  AddIssueSubscriber).
- New internal/handler/github_test:
  TestWebhook_MergedPR_ChildWithParent_NotifiesParent fires a real
  pull_request closed-merged webhook against a child and asserts the
  parent receives exactly one safe system comment with the workspace's
  real identifier (no `mention://agent|member|squad` links).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 14:10:56 +08:00
Jiang Bohan
548fad4f7b feat(issues): platform-owned parent notify on child done (MUL-2538)
When a child issue transitions from a non-done status into `done` and has
an open parent, the server now posts a top-level platform-generated
comment on the parent itself. Replaces the agent-prompt rule shipped in
PR #2918, which produced self-mention loops, planner ping-pong, and
accidental `MUL-` prefix hardcoding because the agent did not always know
the workspace prefix.

- Migration 107 widens `comment.author_type` to allow `system`; the
  zero UUID is used as the sentinel `author_id` (the column stays NOT
  NULL, callers branch on `author_type === 'system'`).
- `Handler.notifyParentOfChildDone` fires from both `UpdateIssue` and
  `BatchUpdateIssues`. Guards: prev status != done, new status == done,
  parent set, parent not in `done`/`cancelled`. Bypasses the
  CreateComment HTTP path so the assignee on_comment trigger and the
  mention-trigger paths do not fire — the comment content carries only
  the safe issue mention for the child, no `mention://agent/...` /
  `mention://member/...` / `mention://squad/...` links.
- `runtime_config.go` downgrades the Parent/Sub-issue Protocol rule 1
  to an explicit "do NOT post one yourself" guardrail; rule 2 (sub-issue
  creation `--status todo` vs `backlog`) is unchanged.
- New handler test exercises the happy path, idempotency, reopen+done,
  parent done/cancelled guards, and the no-parent case. Runtime-config
  tests reassert the new wording and the banned strings from the prior
  revision.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:53:30 +08:00
13 changed files with 747 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
})
}

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

View 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'));

View 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'));