Skip to content

Commit 743e30e

Browse files
committed
feat(linear): re-home first-run courtesy comments to the Lambda tier (ADR-016 P4.5)
With the Linear MCP removed, the agent no longer posts its own 'Starting' and 'PR opened' comments. Re-home both to the platform so first-run Linear tasks keep their headline signal: - webhook processor: post '🤖 Starting on this issue…' at task-admission (single-task first-run path only — orchestration/decompose/iteration returned earlier; their panel/reply already narrate start). Best-effort, never gates the run. Lands before the container even cold-starts. - fanout dispatcher: add pr_created to the Linear channel filter and post a '🔗 Opened PR #N: <url>' comment on the pr_created milestone for first-run tasks (iterations still mature their threaded reply). Post-once via a new linear_pr_comment_event_id marker. The terminal ✅/⚠️/❌ comment already carries the authoritative PR link (ABCA-584), so this is early feedback, not the source of truth. Both comments use the 🤖/🔗 bot-prefixes already in the self-trigger guard, so they never re-trigger ABCA. Reactions + state transitions remain agent-side deterministic (linear_reactions.py). Tests updated: Linear now subscribes to pr_created; new dispatcher tests for the PR-opened comment (post, no-url skip, post-once, iteration-matures-reply).
1 parent f7c8730 commit 743e30e

4 files changed

Lines changed: 190 additions & 20 deletions

File tree

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

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -167,20 +167,23 @@ export const CHANNEL_DEFAULTS: Record<NotificationChannel, ReadonlySet<string>>
167167
...TERMINAL_EVENT_TYPES,
168168
'pr_created',
169169
]),
170-
// Linear posts a single deterministic final-status comment on
171-
// terminal events. The agent's three-comment prompt contract (start /
172-
// PR-opened / completion) covers in-flight progress; this dispatcher
173-
// only fires once the task reaches a terminal state, with cost /
174-
// turns / duration / pr_url metrics the requester wouldn't otherwise
175-
// see. Crucially, this fires even when the agent crashes (e.g.
176-
// error_max_turns, OOM) before reaching its own step-3 completion
177-
// comment — the GH issue #239 motivating example.
170+
// Linear posts deterministic status comments on the platform tier
171+
// (ADR-016: Linear is fully deterministic — the agent has no Linear MCP
172+
// and posts nothing itself). Two events:
173+
// * ``pr_created`` — the first-run "🔗 PR opened" courtesy comment (or,
174+
// for a comment-iteration, matures the threaded reply to "🔄 Working").
175+
// This replaces the agent's old step-2 MCP save_comment.
176+
// * terminal — the final ✅/⚠️/❌ status with cost / turns / duration /
177+
// pr_url metrics. Fires even when the agent crashes (error_max_turns,
178+
// OOM) before any PR — the GH issue #239 motivating example.
178179
//
179-
// Linear's `save_comment` doesn't support edit, so this is post-once
180-
// (no live updates a la GitHub edit-in-place). Approvals / milestones
181-
// are excluded for the same reason — N comments rather than 1.
180+
// Linear's `save_comment` doesn't support edit, so each is post-once (no
181+
// live updates a la GitHub edit-in-place), idempotent across partial-batch
182+
// retries via per-event markers. The start "🤖 Starting" comment is posted
183+
// even earlier, at task-admission in the webhook processor (ADR-016 P4.5).
182184
linear: new Set<string>([
183185
...TERMINAL_EVENT_TYPES,
186+
'pr_created',
184187
]),
185188
// Jira posts a single deterministic final-status comment on terminal
186189
// events — the Jira analogue of the Linear default above (issue #573).
@@ -639,6 +642,24 @@ async function saveLinearCommentState(taskId: string, eventId: string): Promise<
639642
});
640643
}
641644

645+
/**
646+
* Persist the post-once marker after a successful first-run Linear "PR opened"
647+
* courtesy comment (ADR-016 P4.5). The pr_created analogue of
648+
* ``saveLinearCommentState`` — a distinct attribute so the PR-opened and
649+
* terminal comments are independently idempotent.
650+
*/
651+
async function saveLinearPrCommentState(taskId: string, eventId: string): Promise<void> {
652+
await saveDispatchMarker({
653+
taskId,
654+
updateExpression: 'SET linear_pr_comment_event_id = :eid',
655+
conditionExpression: 'attribute_exists(task_id) AND attribute_not_exists(linear_pr_comment_event_id)',
656+
values: { ':eid': eventId },
657+
channel: 'linear',
658+
errorId: 'FANOUT_LINEAR_PR_PERSIST_FAILED',
659+
logContext: { event_id: eventId },
660+
});
661+
}
662+
642663
/**
643664
* Persist the post-once marker after a successful Jira final-status comment
644665
* (see ``dispatchToJira``). The Jira analogue of ``saveLinearCommentState`` —
@@ -925,6 +946,16 @@ async function dispatchToEmail(event: FanOutEvent): Promise<void> {
925946
});
926947
}
927948

949+
/**
950+
* Render the first-run "PR opened" courtesy comment (ADR-016 P4.5). Kept short
951+
* — the 🔗 prefix is in the self-trigger guard's bot-comment markers so it never
952+
* re-triggers ABCA, and the terminal comment carries the authoritative outcome.
953+
*/
954+
export function renderLinearPrOpenedComment(prUrl: string, prNumber: number | null): string {
955+
const ref = prNumber != null ? `PR #${prNumber}` : 'a pull request';
956+
return `🔗 Opened ${ref}: ${prUrl}`;
957+
}
958+
928959
/**
929960
* Render the Linear final-status comment body. Inputs are already
930961
* coerced to native types by the caller; this function only formats.
@@ -1128,9 +1159,13 @@ async function dispatchToLinear(event: FanOutEvent): Promise<void> {
11281159
const triggerCommentId = task.channel_metadata?.trigger_comment_id;
11291160
const isIteration = Boolean(triggerCommentId);
11301161

1131-
// pr_created milestone on an iteration → mature the reply to "🔄 Working".
1132-
// (Non-iteration tasks ignore pr_created here — the agent's own headline
1133-
// "🤖 PR opened" comment covers the first task; the terminal comment follows.)
1162+
// pr_created milestone:
1163+
// * iteration → mature the threaded reply to "🔄 Working".
1164+
// * first-run (non-iteration) → post the "🔗 PR opened" courtesy comment.
1165+
// This replaces the agent's old step-2 `mcp__linear-server__save_comment`
1166+
// (ADR-016 P4.5): the agent has no Linear MCP, so the platform posts it.
1167+
// Post-once via `linear_pr_comment_event_id` so a pr_created redelivery
1168+
// (or a sibling channel's infra-rejection re-run) doesn't duplicate it.
11341169
if (event.event_type === 'agent_milestone') {
11351170
if (isIteration && iterationReplyId && triggerCommentId) {
11361171
await upsertThreadedReply(
@@ -1143,8 +1178,33 @@ async function dispatchToLinear(event: FanOutEvent): Promise<void> {
11431178
}),
11441179
iterationReplyId,
11451180
);
1181+
return;
1182+
}
1183+
// First-run PR-opened comment. Skip if we've already posted it, or if
1184+
// there's no PR URL yet (nothing to link).
1185+
if (task.linear_pr_comment_event_id || !task.pr_url) return;
1186+
const prBody = renderLinearPrOpenedComment(task.pr_url, task.pr_number ?? null);
1187+
const prResult = await postIssueComment(
1188+
{ linearWorkspaceId: workspaceId, registryTableName },
1189+
issueId,
1190+
prBody,
1191+
);
1192+
if (prResult.ok) {
1193+
logger.info('[fanout/linear] PR-opened comment dispatched', {
1194+
event: 'fanout.linear.pr_comment_dispatched',
1195+
task_id: task.task_id,
1196+
issue_id: issueId,
1197+
});
1198+
await saveLinearPrCommentState(task.task_id, event.event_id);
1199+
} else {
1200+
logger.warn('[fanout/linear] PR-opened comment post failed', {
1201+
event: 'fanout.linear.pr_comment_failed',
1202+
error_id: 'FANOUT_LINEAR_PR_COMMENT_FAILED',
1203+
task_id: task.task_id,
1204+
issue_id: issueId,
1205+
});
11461206
}
1147-
return; // milestones never post the top-level status comment
1207+
return; // milestones never post the terminal status comment
11481208
}
11491209

11501210
// Idempotency across partial-batch retries: Linear has no comment

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { cleanupPreScreenedAttachments, downloadScreenAndStoreLinearAttachments,
3131
import {
3232
deleteComment,
3333
fetchRecentComments,
34+
postIssueComment,
3435
reactToComment,
3536
replyToComment,
3637
reportIssueFailure,
@@ -169,6 +170,14 @@ const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
169170
*/
170171
const ACK_CLAIM_TTL_SECONDS = 86_400;
171172

173+
/**
174+
* First-run "starting" courtesy comment (ADR-016 P4.5). The 🤖 prefix matches
175+
* the bot-comment markers the self-trigger guard skips (isBotAuthoredComment),
176+
* so this never re-triggers ABCA. Kept short — the terminal fan-out comment
177+
* carries the outcome + cost + PR link.
178+
*/
179+
const LINEAR_START_COMMENT = '🤖 Starting on this issue — I\'ll open a PR and report back here when it\'s ready.';
180+
172181
/**
173182
* Post a Linear comment + ❌ reaction without ever propagating an error.
174183
*
@@ -1031,6 +1040,30 @@ export async function handler(event: ProcessorEvent): Promise<void> {
10311040
request_id: requestId,
10321041
});
10331042

1043+
// ADR-016 P4.5: post the first-run "🤖 Starting" courtesy comment from the
1044+
// Lambda tier. This used to be the agent's own `mcp__linear-server__save_comment`
1045+
// call — with the Linear MCP removed (Linear is fully deterministic), the
1046+
// platform owns the comment. Only the single-task first-run path posts it:
1047+
// orchestration/decompose seeds and comment-iterations returned earlier (their
1048+
// panel / maturing reply already narrate start). Best-effort — never gates the
1049+
// run that already started. The 👀 reaction + In Progress transition still
1050+
// happen agent-side (linear_reactions.react_task_started); this is the human-
1051+
// readable companion, posted at admission so it lands before the container
1052+
// cold-starts. The terminal ✅/⚠️/❌ + PR link is posted by the fan-out plane.
1053+
if (WORKSPACE_REGISTRY_TABLE) {
1054+
try {
1055+
await postIssueComment(
1056+
{ linearWorkspaceId: workspaceId, registryTableName: WORKSPACE_REGISTRY_TABLE },
1057+
issue.id,
1058+
LINEAR_START_COMMENT,
1059+
);
1060+
} catch (err) {
1061+
logger.warn('Failed to post Linear start comment (non-fatal)', {
1062+
issue_id: issue.id, error: err instanceof Error ? err.message : String(err),
1063+
});
1064+
}
1065+
}
1066+
10341067
// Multi-part hint (customer-caught): a PLAIN ``bgagent`` label on an issue
10351068
// that looks like several separate parts still runs as ONE task — but the
10361069
// reviewer never saw a plan. Post a one-time, non-blocking nudge that

cdk/src/handlers/shared/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,14 @@ export interface TaskRecord {
218218
* record). Absent until the first successful post.
219219
*/
220220
readonly linear_final_comment_event_id?: string;
221+
/**
222+
* Event ID of the ``pr_created`` milestone whose Linear "PR opened"
223+
* courtesy comment was successfully posted (fan-out plane, ADR-016 P4.5).
224+
* Makes the first-run PR-opened comment post-once across partial-batch
225+
* Lambda retries — the analogue of ``linear_final_comment_event_id`` for
226+
* the mid-run PR notification. Absent until the first successful post.
227+
*/
228+
readonly linear_pr_comment_event_id?: string;
221229
/**
222230
* Event ID of the terminal event whose Jira final-status comment was
223231
* successfully posted (fan-out plane). Jira has no comment edit API,

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

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ function mkEvent(type: string, taskId = 't-1'): DynamoDBRecord {
198198
});
199199
}
200200

201+
/** An ``agent_milestone`` event carrying ``metadata.milestone`` — the real
202+
* shape the agent emits for pr_created (progress_writer.write_agent_milestone),
203+
* which ``effectiveEventType`` unwraps for routing. */
204+
function mkMilestone(milestone: string, taskId = 't-1'): DynamoDBRecord {
205+
return mkRecord('INSERT', {
206+
task_id: { S: taskId },
207+
event_id: { S: `01ABC${milestone}` },
208+
event_type: { S: 'agent_milestone' },
209+
timestamp: { S: '2026-04-22T04:00:00Z' },
210+
metadata: { M: { milestone: { S: milestone } } },
211+
});
212+
}
213+
201214
describe('fanout-task-events: parseStreamRecord', () => {
202215
test('parses a well-formed INSERT into FanOutEvent', () => {
203216
const rec = mkEvent('task_completed', 't-parse-1');
@@ -350,9 +363,10 @@ describe('fanout-task-events: per-channel filter contract (design §6.2)', () =>
350363
]);
351364
});
352365

353-
test('Linear subscribes to terminal events only (post-once final-status comment)', () => {
366+
test('Linear subscribes to pr_created + terminal events (ADR-016 P4.5 courtesy comment + post-once final-status)', () => {
354367
const f = CHANNEL_DEFAULTS.linear;
355368
expect([...f].sort()).toEqual([
369+
'pr_created',
356370
'task_cancelled',
357371
'task_completed',
358372
'task_failed',
@@ -496,9 +510,9 @@ describe('fanout-task-events: routeEvent (per-channel dispatch)', () => {
496510
expect(outcome.dispatched).toEqual(['slack']);
497511
});
498512

499-
test('pr_created routes to GitHub only (not Slack — task_completed already carries View PR)', async () => {
513+
test('pr_created routes to GitHub + Linear (ADR-016 P4.5 courtesy comment), not Slack', async () => {
500514
const outcome = await routeEvent(mk('pr_created'));
501-
expect(outcome.dispatched).toEqual(['github']);
515+
expect([...outcome.dispatched].sort()).toEqual(['github', 'linear']);
502516
expect(outcome.dispatched).not.toContain('slack');
503517
});
504518

@@ -1541,6 +1555,58 @@ describe('fanout-task-events: Linear dispatcher (issue #239)', () => {
15411555
expect(mockPostIssueComment).not.toHaveBeenCalled();
15421556
});
15431557

1558+
// ─── ADR-016 P4.5: first-run "PR opened" courtesy comment on pr_created ─────
1559+
1560+
test('pr_created posts a "🔗 Opened PR" comment for a first-run task', async () => {
1561+
mockGet({ ...TASK_RECORD_LINEAR, pr_number: 13 });
1562+
1563+
const event: DynamoDBStreamEvent = { Records: [mkMilestone('pr_created', 't-lin')] };
1564+
await handler(event);
1565+
1566+
expect(mockPostIssueComment).toHaveBeenCalledTimes(1);
1567+
const [, issueId, body] = mockPostIssueComment.mock.calls[0];
1568+
expect(issueId).toBe('issue-uuid-42');
1569+
expect(body).toContain('🔗');
1570+
expect(body).toContain('PR #13');
1571+
expect(body).toContain('https://github.com/owner/repo/pull/13');
1572+
});
1573+
1574+
test('pr_created without a PR url yet posts nothing (nothing to link)', async () => {
1575+
mockGet({ ...TASK_RECORD_LINEAR, pr_url: undefined });
1576+
1577+
const event: DynamoDBStreamEvent = { Records: [mkMilestone('pr_created', 't-lin')] };
1578+
await handler(event);
1579+
1580+
expect(mockPostIssueComment).not.toHaveBeenCalled();
1581+
});
1582+
1583+
test('pr_created is post-once — already-posted marker skips the comment', async () => {
1584+
mockGet({ ...TASK_RECORD_LINEAR, pr_number: 13, linear_pr_comment_event_id: 'prior-event' });
1585+
1586+
const event: DynamoDBStreamEvent = { Records: [mkMilestone('pr_created', 't-lin')] };
1587+
await handler(event);
1588+
1589+
expect(mockPostIssueComment).not.toHaveBeenCalled();
1590+
});
1591+
1592+
test('pr_created on an iteration matures the threaded reply instead of posting a top-level comment', async () => {
1593+
mockGet({
1594+
...TASK_RECORD_LINEAR,
1595+
pr_number: 13,
1596+
channel_metadata: {
1597+
...TASK_RECORD_LINEAR.channel_metadata,
1598+
trigger_comment_id: 'trigger-c1',
1599+
iteration_reply_comment_id: 'reply-c1',
1600+
},
1601+
});
1602+
1603+
const event: DynamoDBStreamEvent = { Records: [mkMilestone('pr_created', 't-lin')] };
1604+
await handler(event);
1605+
1606+
expect(mockUpsertThreadedReply).toHaveBeenCalledTimes(1);
1607+
expect(mockPostIssueComment).not.toHaveBeenCalled();
1608+
});
1609+
15441610
test('Linear-origin task missing channel_metadata.linear_issue_id — skip with warning', async () => {
15451611
// Defensive: a properly-admitted Linear task should always have
15461612
// these fields, but if it doesn't we'd rather log + skip than
@@ -2496,9 +2562,12 @@ describe('fanout-task-events: agent_milestone routing (effective event type)', (
24962562
expect(shouldFanOut(colliding)).toBe(false);
24972563
});
24982564

2499-
test('routeEvent dispatches agent_milestone(pr_created) to GitHub only (Slack opted out to avoid duplicate View PR)', async () => {
2565+
test('routeEvent dispatches agent_milestone(pr_created) to GitHub + Linear (ADR-016 P4.5), not Slack', async () => {
2566+
// Slack stays opted out (task_completed carries View PR); Linear joined
2567+
// for the first-run "🔗 PR opened" courtesy comment.
25002568
const outcome = await routeEvent(makeMilestone('pr_created'));
2501-
expect(outcome.dispatched).toEqual(['github']);
2569+
expect([...outcome.dispatched].sort()).toEqual(['github', 'linear']);
2570+
expect(outcome.dispatched).not.toContain('slack');
25022571
});
25032572

25042573
test('routeEvent drops agent_milestone(agent_turn-like) that no channel subscribes to', async () => {

0 commit comments

Comments
 (0)