fix(routing): comment→CEO wakeup read issue id from event.entityId, not payload.issueId

The `issue.comment.created` handler read `payload.issueId`, which the current
Paperclip host never sends — the issue id arrives in `event.entityId` and the
payload carries `commentId`/`bodySnippet`/`reopened` instead. So the handler
hit "missing issueId, skipping" on every user comment and the documented
"user comment → CEO" routing was silently dead.

Diagnosed on case 8124-09-24 (CMPA): a question commented on an analyst's
`done` sub-issue never reached the CEO. The plugin skipped; Paperclip's native
reopen-on-comment wake then targeted the sub-issue's assignee (the analyst) and
the queued CEO run was cancelled with `issue_assignee_changed`.

Fix:
- issueId from `event.entityId` (fallback `payload.issueId` for host-version skew).
- Resolve full comment body by matching `payload.commentId` in listComments
  (payload only has a truncated `bodySnippet`); fall back to latest/snippet.
- Dedup guard: when the comment reopened an issue ALREADY assigned to the CEO,
  the host's native wake covers it — skip to avoid double-running the CEO.
  For issues owned by any other agent we still route to the CEO.

Invariants: upholds the "user comment routes through CEO" contract (CLAUDE.md);
touches only the platform-port shell (plugin), per G12/X15. No new parallel path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 04:55:08 +00:00
parent f70023fccc
commit 2d6dd8a072

View File

@@ -540,27 +540,36 @@ const plugin = definePlugin({
if (event.actorType !== "user") return;
if (!event.companyId) return;
// entityId is the comment ID — fetch the comment to get issueId + body
const entityId = event.entityId;
if (!entityId) return;
// The event payload may contain the issueId directly
const payload = event.payload as {
// Event/payload shape for `issue.comment.created` (Paperclip host):
// event.entityId → the ISSUE id (the primary entity of the event)
// event.payload.commentId → the comment id
// event.payload.bodySnippet → TRUNCATED body (not the full text)
// event.payload.reopened / reopenedFrom → set when the comment
// reopened a `done` issue (host then natively wakes its assignee)
// NOTE: the host does NOT send `payload.issueId` and does NOT send the
// full `body`. The earlier code read `payload.issueId` (always absent)
// and skipped every event — silently disabling comment→CEO routing.
// See @paperclipai/plugin-sdk index.d.ts: `issueId: event.entityId`.
const payload = (event.payload ?? null) as {
issueId?: string;
commentId?: string;
body?: string;
bodySnippet?: string;
reopened?: boolean;
reopenedFrom?: string;
} | null;
const issueId = payload?.issueId;
let commentBody = payload?.body;
// If issueId is not in payload, try to find it from the comment entity
// issueId: prefer entityId (current host), fall back to payload for
// forward/backward compatibility with other host versions.
const issueId = event.entityId || payload?.issueId;
if (!issueId) {
ctx.logger.warn(
"issue.comment.created event missing issueId in payload, skipping",
{ entityId, payload },
"issue.comment.created event missing issueId (entityId+payload empty), skipping",
{ entityId: event.entityId, payload },
);
return;
}
const commentId = payload?.commentId;
// Fetch issue details for context
const issue = await ctx.issues.get(issueId, event.companyId);
@@ -571,20 +580,6 @@ const plugin = definePlugin({
return;
}
// If comment body not in payload, fetch from API
if (!commentBody) {
try {
const comments = await ctx.issues.listComments(
issueId,
event.companyId,
);
const latest = comments[comments.length - 1];
commentBody = latest?.body || "(לא ניתן לקרוא את התגובה)";
} catch {
commentBody = "(שגיאה בקריאת התגובה)";
}
}
// Wake the CEO agent for this company
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
if (!ceoAgentId) {
@@ -594,6 +589,45 @@ const plugin = definePlugin({
return;
}
// Dedup against the host's native reopen-on-comment wake: when a comment
// reopens a `done` issue, the host already wakes that issue's assignee.
// If the assignee IS this company's CEO, that native wake covers us —
// invoking again would double-run the CEO. Skip ONLY in that exact case.
// For issues owned by any other agent (e.g. an analyst sub-task), the
// native wake targets the wrong agent (and the queued run is cancelled
// with `issue_assignee_changed`), so we MUST route the comment to the CEO.
if (issue.assigneeAgentId === ceoAgentId && payload?.reopened === true) {
ctx.logger.info(
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
{ issueId, ceoAgentId },
);
return;
}
// Resolve the full comment body. The payload only carries a truncated
// snippet, so fetch the comment list and match by id (fall back to the
// latest comment, then the snippet).
let commentBody = payload?.body;
if (!commentBody) {
try {
const comments = await ctx.issues.listComments(
issueId,
event.companyId,
);
const matched = commentId
? comments.find((c) => c.id === commentId)
: undefined;
const latest = comments[comments.length - 1];
commentBody =
matched?.body ||
latest?.body ||
payload?.bodySnippet ||
"(לא ניתן לקרוא את התגובה)";
} catch {
commentBody = payload?.bodySnippet || "(שגיאה בקריאת התגובה)";
}
}
try {
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
prompt: [
@@ -608,7 +642,7 @@ const plugin = definePlugin({
});
ctx.logger.info("Routed user comment to CEO agent", {
issueId,
commentId: entityId,
commentId: commentId || null,
runId,
});
} catch (err) {