From b9a5bb6e8013f2983ea9776581054c7e99e01522 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Tue, 30 Jun 2026 11:41:02 +0000 Subject: [PATCH] fix(sweep): pick newest chair comment by timestamp, not array position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep assumed listComments returns oldest-first and took comments[length-1] as "the last comment". listComments actually returns newest-first, so the sweep routed the OLDEST user comment instead of the newest — and routed it whether or not an agent had already answered. Caught live on CMPA-116: the sweep routed a stale 6-day-old "agent didn't activate" gripe (already answered minutes later) instead of the chair's real pending request ("I attached case law — go back to the precedent-researcher and check the docs"), so the CEO correctly no-op'd and the real request stayed dropped. Fix: order-independent. Compute the newest USER comment and newest AGENT comment by createdAt; route only when the chair spoke last among real participants (newest user > newest agent, or no agent comment), so an agent reply — but NOT an automated system "draft ready" line — counts as handled. Also fix the same latent array-order assumption in the event-handler body fallback (reduce-by-createdAt instead of comments[length-1]). Self-heals the stuck marker on CMPA-116 (newest-user id differs from the wrongly-stamped one). Refs legal-ai #164. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/worker.ts | 49 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index fc546ce..5bf7e12 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -697,7 +697,16 @@ const plugin = definePlugin({ const matched = commentId ? comments.find((c) => c.id === commentId) : undefined; - const latest = comments[comments.length - 1]; + // Newest by createdAt — do NOT rely on listComments array order. + const latest = comments.reduce<(typeof comments)[number] | null>( + (acc, c) => + !acc || + new Date(c.createdAt).getTime() > + new Date(acc.createdAt).getTime() + ? c + : acc, + null, + ); commentBody = matched?.body || latest?.body || @@ -997,14 +1006,28 @@ const plugin = definePlugin({ ); if (comments.length === 0) continue; - // "Pending" = the conversation's last word is the chair's, with - // no agent reply after it. listComments is chronological (newest - // last). If an agent already responded, the comment was handled — - // skip. This both targets the exact failure (chair commented, no - // agent response) and avoids re-routing historically-answered - // comments on first run after deploy. - const last = comments[comments.length - 1]; - if (last.authorType !== "user") continue; + // "Pending" = the chair spoke last among real participants: the + // newest USER comment is newer than the newest AGENT comment (or + // no agent has commented yet), so an agent never responded to it. + // Order-independent (sort by createdAt, do NOT rely on the array + // order of listComments) and system comments are ignored — an + // automated "draft ready" line is not an agent response. This + // targets the exact failure (chair commented, no agent reply) and + // skips historically-answered comments. + const ts = (c: { createdAt: string | Date }) => + new Date(c.createdAt).getTime(); + let newestUser: (typeof comments)[number] | null = null; + let newestAgent: (typeof comments)[number] | null = null; + for (const c of comments) { + if (c.authorType === "user") { + if (!newestUser || ts(c) > ts(newestUser)) newestUser = c; + } else if (c.authorType === "agent") { + if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c; + } + } + if (!newestUser) continue; // no chair comment + // An agent has already responded after the chair — handled. + if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue; const marker = await ctx.state.get({ scopeKind: "issue", @@ -1012,18 +1035,18 @@ const plugin = definePlugin({ stateKey: LAST_ROUTED_COMMENT_KEY, }); // Already routed (CEO may still be working) — don't re-fire. - if (marker === last.id) continue; + if (marker === newestUser.id) continue; ctx.logger.info( "route-pending-comments: re-routing unrouted user comment", - { issueId: issue.id, commentId: last.id }, + { issueId: issue.id, commentId: newestUser.id }, ); await routeCommentToCeo({ issue, companyId: company.id, ceoAgentId, - commentBody: last.body, - commentId: last.id, + commentBody: newestUser.body, + commentId: newestUser.id, source: "sweep", }); } catch (err) {