Skip to content

Commit f541481

Browse files
bgagentclaude
andcommitted
feat(linear): comment + react on pre-container task-creation failures
Closes the silent-drop UX gap that appeared whenever a Linear-triggered task was rejected before the agent container started — the user would apply the trigger label, see nothing happen, and have no way to know why. Reactions and progress comments are emitted by the agent container; nothing fired until that point, so all upstream rejections were invisible on the Linear side. This commit wires a best-effort GraphQL feedback path covering all six distinct rejection points: In `linear-webhook-processor.ts` (pre-`createTaskCore`): 1. Issue has no projectId → "isn't in a project" comment 2. Project not onboarded / removed → "isn't onboarded; admin can run `bgagent linear onboard-project`" comment 3. Webhook missing organization or actor → diagnostic comment 4. Linear actor has no linked platform user → "v1 only the API-token owner can submit; multi-user OAuth is on the v3 roadmap" comment 5. `createTaskCore` returns non-201 → message branched on status: guardrail/validation block surfaces the user-facing error string; 503 prompts the user to re-apply the label; other 4xx/5xx falls through to a generic message. In `orchestrate-task.ts` (post-201, in admission control): 6. User concurrency cap rejection → "concurrency limit; wait for one to finish, then re-apply the label" comment. All five processor paths and the orchestrator path call a shared helper, `reportIssueFailure(secretArn, issueId, message)`, that runs the comment and ❌ reaction in parallel via `Promise.allSettled`. The helper: - Reuses the existing 5-minute `getLinearSecret` cache from `linear-verify.ts` (no extra Secrets Manager hits on warm Lambdas). - Swallows network, auth, and GraphQL errors with WARN logs — Linear feedback is advisory and must never gate the rejection path. - Posts to Linear's hosted GraphQL endpoint; mutation shapes match `agent/src/linear_reactions.py` (`commentCreate`, `reactionCreate`). CDK plumbing: - `linear-integration.ts` — wires `LINEAR_API_TOKEN_SECRET_ARN` into the webhook processor and grants read on the existing `LinearIntegration.apiTokenSecret`. - `agent.ts` — grants the same secret to `orchestrator.fn` and populates the env var. The grant is unconditional; the orchestrator only invokes the helper when `task.channel_source === 'linear'`. The non-Linear case is a hard no-op at the call site — `notifyLinear- OnConcurrencyCap` early-returns on `channel_source !== 'linear'`, and the processor only handles Linear payloads. Slack/API/webhook tasks are unaffected. Tests (28 new; 1240 → 1268, all green): - `cdk/test/handlers/shared/linear-feedback.test.ts` (13 tests): mutation shape, auth header, error swallowing in 4 distinct failure modes (secret-resolution null, non-2xx, GraphQL `errors`, network throw), `Promise.allSettled` partial-success semantics. - `cdk/test/handlers/linear-webhook-processor.test.ts` (10 new tests in a `user-visible feedback` describe block): one assertion per rejection path + happy-path-doesn't-fire + filter-rejection-doesn't- fire (the latter is intentional UX — the processor sees many events that aren't tasks, and dropping a comment on each would be noisy). - `cdk/test/handlers/orchestrate-task-feedback.test.ts` (5 tests): new file; covers `notifyLinearOnConcurrencyCap` directly with `withDurableExecution` mocked. Asserts the linear path fires; the api/webhook/slack paths no-op; missing metadata, missing env, and undefined `channel_metadata` all no-op cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9592796 commit f541481

8 files changed

Lines changed: 701 additions & 0 deletions

File tree

cdk/src/constructs/linear-integration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,13 @@ export class LinearIntegration extends Construct {
181181
...createTaskEnv,
182182
LINEAR_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName,
183183
LINEAR_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName,
184+
LINEAR_API_TOKEN_SECRET_ARN: this.apiTokenSecret.secretArn,
184185
},
185186
bundling: commonBundling,
186187
});
187188
this.projectMappingTable.grantReadData(webhookProcessorFn);
188189
this.userMappingTable.grantReadData(webhookProcessorFn);
190+
this.apiTokenSecret.grantRead(webhookProcessorFn);
189191
props.taskTable.grantReadWriteData(webhookProcessorFn);
190192
props.taskEventsTable.grantReadWriteData(webhookProcessorFn);
191193
if (props.repoTable) {

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import * as crypto from 'crypto';
2121
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2222
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
2323
import { createTaskCore } from './shared/create-task-core';
24+
import { reportIssueFailure } from './shared/linear-feedback';
2425
import { logger } from './shared/logger';
2526

2627
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
2728

2829
const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!;
2930
const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!;
31+
const API_TOKEN_SECRET_ARN = process.env.LINEAR_API_TOKEN_SECRET_ARN!;
3032
const DEFAULT_LABEL_FILTER = 'bgagent';
3133

3234
/** Shape of Linear `Issue` webhook payloads we care about. Undocumented fields are tolerated. */
@@ -100,6 +102,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
100102
logger.info('Linear Issue has no projectId — skipping (cannot route to a repo)', {
101103
issue_id: issue.id,
102104
});
105+
await reportIssueFailure(
106+
API_TOKEN_SECRET_ARN,
107+
issue.id,
108+
"❌ This Linear issue isn't in a project — ABCA needs a Linear project to route the task to a repo. Move the issue into a project and re-apply the trigger label.",
109+
);
103110
return;
104111
}
105112

@@ -113,6 +120,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
113120
linear_project_id: projectId,
114121
issue_id: issue.id,
115122
});
123+
await reportIssueFailure(
124+
API_TOKEN_SECRET_ARN,
125+
issue.id,
126+
"❌ This Linear project isn't onboarded to ABCA. An admin can onboard it with `bgagent linear onboard-project <project-uuid> --repo <owner>/<repo> --label <trigger>`.",
127+
);
116128
return;
117129
}
118130
const repo = mapping.Item.repo as string;
@@ -145,6 +157,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
145157
organization_id: workspaceId,
146158
actor_id: actorId,
147159
});
160+
await reportIssueFailure(
161+
API_TOKEN_SECRET_ARN,
162+
issue.id,
163+
"❌ Linear webhook is missing the organization or actor field — ABCA can't attribute this task to a user. This is unusual; please report it to your ABCA admin.",
164+
);
148165
return;
149166
}
150167

@@ -155,6 +172,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
155172
linear_user_id: actorId,
156173
issue_id: issue.id,
157174
});
175+
await reportIssueFailure(
176+
API_TOKEN_SECRET_ARN,
177+
issue.id,
178+
"❌ This Linear user isn't linked to a platform user. In v1 only the API-token owner can submit tasks from Linear; multi-user OAuth support is on the v3 roadmap.",
179+
);
158180
return;
159181
}
160182

@@ -192,6 +214,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
192214
body: result.body,
193215
issue_id: issue.id,
194216
});
217+
await reportIssueFailure(
218+
API_TOKEN_SECRET_ARN,
219+
issue.id,
220+
buildCreateTaskFailureMessage(result.statusCode, result.body),
221+
);
195222
return;
196223
}
197224

@@ -236,6 +263,44 @@ function shouldTrigger(payload: LinearIssueEvent, labelFilter: string): boolean
236263
return false;
237264
}
238265

266+
/**
267+
* Translate a `createTaskCore` non-201 response into a user-facing Linear comment.
268+
*
269+
* The CDK error envelope is `{ error: { code, message, request_id } }`. We surface
270+
* the `message` because it's already user-readable (e.g. "Task description was
271+
* blocked by content policy") and add a per-status prefix so the user can tell
272+
* a guardrail block from a 503 from a validation error.
273+
*
274+
* Falls back to a generic message if the body fails to parse — best-effort, never throws.
275+
*/
276+
function buildCreateTaskFailureMessage(statusCode: number | undefined, rawBody: string | undefined): string {
277+
let detail = '';
278+
try {
279+
if (rawBody) {
280+
const parsed = JSON.parse(rawBody) as { error?: { code?: string; message?: string } };
281+
const message = parsed.error?.message;
282+
if (typeof message === 'string' && message.trim()) {
283+
detail = message.trim();
284+
}
285+
}
286+
} catch {
287+
// fall through to the generic message
288+
}
289+
290+
if (statusCode === 400 && detail) {
291+
// Guardrail blocks and validation errors land here; the message is already
292+
// user-readable so just prefix it.
293+
return `❌ ABCA couldn't accept this task: ${detail}`;
294+
}
295+
if (statusCode === 503) {
296+
return `❌ ABCA is temporarily unavailable (status ${statusCode}). Please re-apply the trigger label in a few minutes.`;
297+
}
298+
if (detail) {
299+
return `❌ ABCA couldn't create this task (status ${statusCode ?? 'unknown'}): ${detail}`;
300+
}
301+
return `❌ ABCA couldn't create this task (status ${statusCode ?? 'unknown'}). Check the ABCA admin logs for details.`;
302+
}
303+
239304
function buildTaskDescription(issue: LinearIssueEvent['data']): string {
240305
const parts: string[] = [];
241306
if (issue.identifier && issue.title) {

cdk/src/handlers/orchestrate-task.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import { withDurableExecution, type DurableExecutionHandler } from '@aws/durable-execution-sdk-js';
2121
import { TaskStatus, TERMINAL_STATUSES } from '../constructs/task-status';
2222
import { resolveComputeStrategy } from './shared/compute-strategy';
23+
import { reportIssueFailure } from './shared/linear-feedback';
2324
import { logger } from './shared/logger';
2425
import {
2526
admissionControl,
@@ -34,6 +35,7 @@ import {
3435
type PollState,
3536
} from './shared/orchestrator';
3637
import { runPreflightChecks } from './shared/preflight';
38+
import type { TaskRecord } from './shared/types';
3739

3840
interface OrchestrateTaskEvent {
3941
readonly task_id: string;
@@ -73,6 +75,7 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
7375
if (!result) {
7476
await failTask(taskId, current.status, 'User concurrency limit reached', task.user_id, false);
7577
await emitTaskEvent(taskId, 'admission_rejected', { reason: 'concurrency_limit' });
78+
await notifyLinearOnConcurrencyCap(task);
7679
}
7780
return result;
7881
});
@@ -265,3 +268,39 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
265268
};
266269

267270
export const handler = withDurableExecution(durableHandler);
271+
272+
/**
273+
* Post a Linear comment + ❌ reaction when admission control rejects a task
274+
* for the user concurrency cap. Linear-only; silently no-ops for other
275+
* channels.
276+
*
277+
* The processor side (`linear-webhook-processor.ts`) already covers
278+
* pre-`createTaskCore` rejections (unmapped project, unlinked actor, guardrail);
279+
* this hook covers the post-201 case where the orchestrator rejects on
280+
* admission. Without this, the only Linear-side signal would be the 👀
281+
* reaction the agent never gets to add — looks like the integration silently
282+
* dropped the request.
283+
*
284+
* Best-effort: errors inside `reportIssueFailure` are swallowed at the helper
285+
* layer; we don't surface them here because Linear feedback must never block
286+
* the rejection path.
287+
*
288+
* Exported for unit testing — the durable handler invokes it inline.
289+
*/
290+
export async function notifyLinearOnConcurrencyCap(task: TaskRecord): Promise<void> {
291+
if (task.channel_source !== 'linear') return;
292+
const issueId = task.channel_metadata?.linear_issue_id;
293+
if (!issueId) return;
294+
const secretArn = process.env.LINEAR_API_TOKEN_SECRET_ARN;
295+
if (!secretArn) {
296+
logger.warn('Skipping Linear concurrency-cap feedback: LINEAR_API_TOKEN_SECRET_ARN not set', {
297+
task_id: task.task_id,
298+
});
299+
return;
300+
}
301+
await reportIssueFailure(
302+
secretArn,
303+
issueId,
304+
'❌ ABCA hit your concurrency limit — too many tasks running for your user. Wait for one to finish, then re-apply the trigger label.',
305+
);
306+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { getLinearSecret } from './linear-verify';
21+
import { logger } from './logger';
22+
23+
/**
24+
* Lambda-side helper for posting comments and reactions onto Linear issues
25+
* via direct GraphQL. Used by the webhook processor to give users feedback
26+
* on pre-container failures (guardrail block, concurrency cap, unmapped
27+
* project, etc.) — paths where the agent never starts and the agent-side
28+
* Linear MCP / `linear_reactions.py` cannot run.
29+
*
30+
* All calls are best-effort. Errors are logged at WARN and swallowed —
31+
* Linear feedback is advisory and must never gate task-rejection logic.
32+
*/
33+
34+
const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
35+
36+
const REQUEST_TIMEOUT_MS = 5000;
37+
38+
/** Reaction emoji short-code for the failure marker. Matches `EMOJI_FAILURE` in `agent/src/linear_reactions.py`. */
39+
const EMOJI_FAILURE = 'x';
40+
41+
const COMMENT_CREATE_MUTATION = `
42+
mutation CreateComment($issueId: String!, $body: String!) {
43+
commentCreate(input: { issueId: $issueId, body: $body }) {
44+
success
45+
}
46+
}
47+
`.trim();
48+
49+
const REACTION_CREATE_MUTATION = `
50+
mutation ReactIssue($issueId: String!, $emoji: String!) {
51+
reactionCreate(input: { issueId: $issueId, emoji: $emoji }) {
52+
success
53+
}
54+
}
55+
`.trim();
56+
57+
async function graphqlRequest(
58+
apiToken: string,
59+
query: string,
60+
variables: Record<string, unknown>,
61+
): Promise<boolean> {
62+
const controller = new AbortController();
63+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
64+
try {
65+
const resp = await fetch(LINEAR_GRAPHQL_URL, {
66+
method: 'POST',
67+
headers: {
68+
'Authorization': apiToken,
69+
'Content-Type': 'application/json',
70+
},
71+
body: JSON.stringify({ query, variables }),
72+
signal: controller.signal,
73+
});
74+
if (!resp.ok) {
75+
logger.warn('Linear feedback GraphQL non-2xx', { status: resp.status });
76+
return false;
77+
}
78+
const body = (await resp.json()) as { errors?: unknown };
79+
if (body.errors) {
80+
logger.warn('Linear feedback GraphQL errors', { errors: body.errors });
81+
return false;
82+
}
83+
return true;
84+
} catch (err) {
85+
logger.warn('Linear feedback request failed', {
86+
error: err instanceof Error ? err.message : String(err),
87+
});
88+
return false;
89+
} finally {
90+
clearTimeout(timer);
91+
}
92+
}
93+
94+
async function resolveToken(secretArn: string): Promise<string | null> {
95+
try {
96+
return await getLinearSecret(secretArn);
97+
} catch (err) {
98+
logger.warn('Linear feedback could not resolve API token', {
99+
error: err instanceof Error ? err.message : String(err),
100+
});
101+
return null;
102+
}
103+
}
104+
105+
/**
106+
* Post a comment onto a Linear issue. Returns true on success, false on any failure
107+
* (network, auth, GraphQL errors). Never throws — callers proceed regardless.
108+
*/
109+
export async function postIssueComment(
110+
apiTokenSecretArn: string,
111+
issueId: string,
112+
body: string,
113+
): Promise<boolean> {
114+
const token = await resolveToken(apiTokenSecretArn);
115+
if (!token) return false;
116+
return graphqlRequest(token, COMMENT_CREATE_MUTATION, { issueId, body });
117+
}
118+
119+
/**
120+
* Add an emoji reaction onto a Linear issue. Defaults to ❌ — the failure marker
121+
* the agent uses on the success/failure side. Returns true on success.
122+
*/
123+
export async function addIssueReaction(
124+
apiTokenSecretArn: string,
125+
issueId: string,
126+
emoji: string = EMOJI_FAILURE,
127+
): Promise<boolean> {
128+
const token = await resolveToken(apiTokenSecretArn);
129+
if (!token) return false;
130+
return graphqlRequest(token, REACTION_CREATE_MUTATION, { issueId, emoji });
131+
}
132+
133+
/**
134+
* Convenience: post a feedback comment **and** drop a ❌ reaction in one call.
135+
* Both calls run in parallel; both are best-effort. Returns void — callers
136+
* never branch on the result.
137+
*/
138+
export async function reportIssueFailure(
139+
apiTokenSecretArn: string,
140+
issueId: string,
141+
message: string,
142+
): Promise<void> {
143+
await Promise.allSettled([
144+
postIssueComment(apiTokenSecretArn, issueId, message),
145+
addIssueReaction(apiTokenSecretArn, issueId, EMOJI_FAILURE),
146+
]);
147+
}

cdk/src/stacks/agent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,18 @@ export class AgentStack extends Stack {
604604
linearIntegration.apiTokenSecret.secretArn,
605605
);
606606

607+
// Pipe the Linear API token secret into the orchestrator Lambda so the
608+
// concurrency-cap rejection path can post a Linear comment + ❌ instead
609+
// of silently dropping the task. The orchestrator only uses the secret
610+
// when `task.channel_source === 'linear'`, but the IAM grant is
611+
// unconditional — the secret is created lazily via Secrets Manager and
612+
// costs nothing if unused.
613+
linearIntegration.apiTokenSecret.grantRead(orchestrator.fn);
614+
orchestrator.fn.addEnvironment(
615+
'LINEAR_API_TOKEN_SECRET_ARN',
616+
linearIntegration.apiTokenSecret.secretArn,
617+
);
618+
607619
new CfnOutput(this, 'LinearWebhookSecretArn', {
608620
value: linearIntegration.webhookSecret.secretArn,
609621
description: 'Secrets Manager ARN for the Linear webhook signing secret — populate via `bgagent linear setup`',

0 commit comments

Comments
 (0)