Skip to content

Commit 3fc1963

Browse files
authored
fix(fanout): render the PR link on ✅ success, not only on ⚠️ (ABCA-584) (#601)
* fix(fanout): render the PR link on ✅ success, not only on ⚠️ (F-prlink) The Linear completion comment rendered the PR URL ONLY on the ⚠️ "shipped a PR but stopped early" path, on the assumption that on ✅ success the agent's own step-2 "PR opened" comment reliably carries the link, so duplicating it is noise. That assumption fails when the agent skips its PR-opened comment. Live-caught on ABCA-584: a :decompose that declined to split ran as one coding task, opened PR #395 (pr_url + build_passed on the task record), but posted NO "🔗 PR opened" comment — so the ✅ "Task completed" comment omitted the link and it was lost entirely; the user asked "where is the PR link?". Fix: render `PR: <url>` whenever the task produced one, on BOTH ✅ and ⚠️. The completion comment is the terminal, platform-owned surface, so it must carry the link rather than depend on the agent choosing to post its own; a duplicate with the agent's comment (when it does fire) is far cheaper than a missing PR, and this is the one terminal comment per task so it can't spam. Test updated to assert the link renders on ✅. Full cdk build green (2935 tests). Held. * docs+test: address review nits N1–N3 on the PR-link render Optional polish from the #601 review (fix itself approved, unchanged): - N1: add a negative assertion for the branch this change now makes live — ✅ task_completed + prUrl null → NO `PR:` line. The relaxed `if (args.prUrl)` guard means this is no longer structurally guaranteed by the old ⚠️-only condition, so pin its absence (fanout-task-events.test.ts). - N2: reword the "ONE terminal comment per task" comment — the single-post guarantee lives in the caller `dispatchToLinear` (linear_final_comment_event_id idempotency marker), NOT this pure formatter, which enforces nothing. Credit the right layer so a future non-idempotent caller isn't misled. - N3: add a one-line note atop the function JSDoc — prUrl, when present, renders on ALL frames — so the "why" isn't split between the JSDoc (ABCA-91 story) and the render-site note (ABCA-584) 50 lines below. No behavior change. fanout-task-events tests green (108); eslint clean.
1 parent 002079d commit 3fc1963

2 files changed

Lines changed: 48 additions & 16 deletions

File tree

cdk/src/handlers/fanout-task-events.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,12 @@ async function dispatchToEmail(event: FanOutEvent): Promise<void> {
850850
* Render the Linear final-status comment body. Inputs are already
851851
* coerced to native types by the caller; this function only formats.
852852
*
853+
* ``prUrl``, when present, renders on ALL frames — the ✅ success frame as much
854+
* as the ⚠️ shipped-but-stopped one. The completion comment is the terminal,
855+
* platform-owned surface, so it must carry the PR link authoritatively rather
856+
* than assume the agent's own "PR opened" comment did (the ABCA-584 case — see
857+
* the render note below). Only the framing/header flips on the outcome.
858+
*
853859
* The framing flips between three outcomes based on `(eventType, prUrl)`:
854860
*
855861
* 1. ``task_completed`` → ✅ "Task completed"
@@ -906,14 +912,21 @@ export function renderLinearFinalStatusComment(args: {
906912
'',
907913
`cost: ${costStr} • turns: ${turnsStr} • duration: ${durationStr}`,
908914
];
909-
// Render the PR URL only on the ⚠️ "shipped a PR but stopped early"
910-
// path — that's the case where the agent's own step-2 "PR opened"
911-
// comment is *not* guaranteed to have fired (the agent may have
912-
// crashed between opening the PR and posting the comment, e.g.
913-
// ABCA-91 hitting max-turns on turn 101). On the ✅ success path the
914-
// agent's step-2 comment reliably carries the PR link, so duplicating
915-
// it here is just noise.
916-
if (args.prUrl && shippedDespiteFailure) {
915+
// Render the PR URL whenever the task produced one — on BOTH the ✅ success and
916+
// the ⚠️ "shipped a PR but stopped early" paths. The prior code rendered it only
917+
// on ⚠️, on the assumption that "on ✅ the agent's own step-2 'PR opened' comment
918+
// reliably carries the link, so duplicating it is noise." That assumption FAILS
919+
// when the agent skips its PR-opened comment — live-caught on ABCA-584, where a
920+
// decompose→single task opened PR #395 (pr_url on the record) but posted no
921+
// PR-opened comment, so the ✅ completion comment omitted it and the link was
922+
// LOST entirely. The completion comment is the terminal, platform-owned surface;
923+
// rendering pr_url here guarantees the link is never lost, and a duplicate with
924+
// the agent's own comment is far cheaper than a missing PR. (Duplicate terminal
925+
// comments can't spam because the CALLER, `dispatchToLinear`, posts this body at
926+
// most once per task via the `linear_final_comment_event_id` idempotency marker
927+
// — this formatter is pure and enforces nothing; a future caller on a
928+
// non-idempotent path would need its own guard.)
929+
if (args.prUrl) {
917930
lines.push('', `PR: ${args.prUrl}`);
918931
}
919932
lines.push('', `_task ${args.taskId}_`);

cdk/test/handlers/fanout-task-events.test.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,14 +1382,14 @@ describe('fanout-task-events: Linear dispatcher (issue #239)', () => {
13821382
expect(body).toContain('$0.55');
13831383
expect(body).toContain('27 / 100');
13841384
expect(body).toContain('3m 41s');
1385-
// PR URL is intentionally NOT rendered on the ✅ success path
1386-
// the agent's step-2 "PR opened" comment already carries it, so
1387-
// duplicating it here just stacks two near-identical links on the
1388-
// Linear issue. (Smoke-test feedback after the first dev deploy.)
1389-
// The ⚠️ "shipped a PR but stopped early" path DOES render it,
1390-
// because the agent may have crashed before its step-2 comment
1391-
// fired — see the ABCA-91 case test below.
1392-
expect(body).not.toContain('https://github.com/owner/repo/pull/13');
1385+
// F-prlink (ABCA-584): the PR URL IS rendered on the ✅ success path. The old
1386+
// behavior omitted it, assuming the agent's own step-2 "PR opened" comment
1387+
// always carries it — but that comment can silently not fire (live-caught: a
1388+
// decompose→single task opened a PR but posted no PR-opened comment, so the
1389+
// link was lost entirely). The terminal completion comment is the
1390+
// platform-owned surface, so it must carry the link; a duplicate is far
1391+
// cheaper than a missing PR.
1392+
expect(body).toContain('https://github.com/owner/repo/pull/13');
13931393
expect(body).toContain('t-lin');
13941394
});
13951395

@@ -1665,6 +1665,25 @@ describe('renderLinearFinalStatusComment', () => {
16651665
expect(body).not.toContain('27 /');
16661666
});
16671667

1668+
test('✅ task_completed with prUrl null → NO PR line (ABCA-584 guard)', () => {
1669+
// Relaxing the render guard to `if (args.prUrl)` makes "✅ completed +
1670+
// prUrl null → no PR: line" a LIVE branch (it was previously structurally
1671+
// guaranteed by the old ⚠️-only condition). Pin its absence so a future
1672+
// stray or `PR: null` line can't creep in unnoticed.
1673+
const body = renderLinearFinalStatusComment({
1674+
eventType: 'task_completed',
1675+
prUrl: null,
1676+
costUsd: 0.5,
1677+
turns: 10,
1678+
maxTurns: 100,
1679+
durationS: 60,
1680+
taskId: 't',
1681+
errorTitle: null,
1682+
});
1683+
expect(body).toContain('✅');
1684+
expect(body).not.toContain('PR:');
1685+
});
1686+
16681687
test('formatDuration: under 60s → seconds only', () => {
16691688
const body = renderLinearFinalStatusComment({
16701689
eventType: 'task_completed',

0 commit comments

Comments
 (0)