feat(linear): comment + react on pre-container task-creation failures#87
Draft
isadeks wants to merge 1 commit into
Draft
feat(linear): comment + react on pre-container task-creation failures#87isadeks wants to merge 1 commit into
isadeks wants to merge 1 commit into
Conversation
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) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the silent-drop UX gap on Linear-triggered tasks that fail before the agent container starts. v1 had six distinct rejection points where the user would apply the trigger label, see nothing happen, and have no Linear-side signal — neither a comment, nor a state change, nor the 👀 reaction (which the agent applies from inside the container). This PR adds a best-effort GraphQL comment + ❌ reaction on each path.
Recommended-v1.1 follow-up to PR #63 (Alain's review explicitly flagged this as the top recommended item).
Paths covered
In
linear-webhook-processor.ts(pre-createTaskCore):projectIdbgagent linear onboard-project"createTaskCorereturns non-201In
orchestrate-task.ts(post-201, in admission control):Architecture
All six call sites use a shared helper
cdk/src/handlers/shared/linear-feedback.ts:Design notes:
errors) are caught and logged at WARN. Linear feedback must never gate the rejection path itself.getLinearSecretfromlinear-verify.tsis a 5-minute in-memory cache, so warm Lambdas don't hit Secrets Manager on every rejection.agent/src/linear_reactions.py. Same GraphQL endpoint, samecommentCreate/reactionCreatemutation shapes — kept consistent so when the Linear MCP server eventually exposescreate_reaction(currently absent), both call sites can migrate together.notifyLinearOnConcurrencyCapearly-returns onchannel_source !== 'linear'. Slack/API/webhook tasks are unaffected; the IAM grant on the orchestrator is unconditional but costs nothing if unused.Plumbing
linear-integration.ts— wiresLINEAR_API_TOKEN_SECRET_ARNinto the webhook processor and grants read onLinearIntegration.apiTokenSecret.agent.ts— same wiring fororchestrator.fn(separate construct; declared afterLinearIntegrationso done in the stack rather than as a prop).LinearIntegrationWebhookProcessorFnandTaskOrchestratorOrchestratorFnboth carry the env var and IAM policy.Test plan
Unit tests — 1240 → 1268, all green:
cdk/test/handlers/shared/linear-feedback.test.ts(new, 13 tests) — mutation shape, auth header, error swallowing in 4 distinct failure modes (secret-resolution null, non-2xx response, GraphQLerrors, network throw),Promise.allSettledpartial-success semantics.cdk/test/handlers/linear-webhook-processor.test.ts— extended with auser-visible feedbackdescribe block, 10 new tests: one assertion per rejection path + happy-path-doesn't-fire + filter-rejection-doesn't-fire (latter is intentional UX — most events aren't tasks; dropping a comment on each would be noisy).cdk/test/handlers/orchestrate-task-feedback.test.ts(new, 5 tests) — coversnotifyLinearOnConcurrencyCapwithwithDurableExecutionmocked. Asserts the linear path fires; api/webhook/slack paths no-op; missing metadata, missing env, undefinedchannel_metadataall no-op cleanly.Lint + synth: clean.
Deployed-stack smoke not run for this PR. The GraphQL mutation pattern is identical to
agent/src/linear_reactions.pywhich has been deployed and exercised in production for weeks; the unit tests cover every rejection path with realistic fixtures, and the IAM/env wiring is verified at synth. Reviewer can deploy and exercise the paths in Linear if they want belt-and-suspenders.Reviewer notes
concurrency_limit_feedbackevent emitted yet — the orchestrator already emitsadmission_rejectedwithreason: 'concurrency_limit'toTaskEventsTable; that hasn't changed. The Linear feedback runs alongside, not replacing.filter-rejectedpath stays silent — when a Linear webhook arrives for an issue that doesn't have the trigger label, no comment fires. This is intentional (most webhooks aren't tasks), and I added a test that pins this behaviour.channel_metadata.linear_issue_idis the only hook into the rejected task on the orchestrator side. This field is set bylinear-webhook-processor.tswhen the task is created and survives on theTaskRecord; no schema change.