Skip to content

feat(linear): comment + react on pre-container task-creation failures#87

Draft
isadeks wants to merge 1 commit into
aws-samples:mainfrom
isadeks:feat/linear-processor-feedback
Draft

feat(linear): comment + react on pre-container task-creation failures#87
isadeks wants to merge 1 commit into
aws-samples:mainfrom
isadeks:feat/linear-processor-feedback

Conversation

@isadeks
Copy link
Copy Markdown
Contributor

@isadeks isadeks commented May 13, 2026

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):

# Trigger Message
1 Issue has no projectId "isn't in a project — ABCA needs a Linear project to route tasks to a repo"
2 Project not onboarded / removed "isn't onboarded to ABCA; an admin can run bgagent linear onboard-project"
3 Webhook missing organization or actor diagnostic; "report to your ABCA admin"
4 Linear actor has no linked platform user "v1 only the API-token owner can submit; multi-user OAuth is on the v3 roadmap"
5 createTaskCore returns non-201 branched on status: guardrail/validation block surfaces the user-facing error string; 503 prompts a retry; other 4xx/5xx falls through to a generic message

In orchestrate-task.ts (post-201, in admission control):

# Trigger Message
6 User concurrency cap rejection "concurrency limit; wait for one to finish, then re-apply the label"

Architecture

All six call sites use a shared helper cdk/src/handlers/shared/linear-feedback.ts:

reportIssueFailure(secretArn, issueId, message)
  → Promise.allSettled([postIssueComment, addIssueReaction])

Design notes:

  • Best-effort. Errors at any layer (secret resolution, network, non-2xx, GraphQL errors) are caught and logged at WARN. Linear feedback must never gate the rejection path itself.
  • Reuses existing secret cache. getLinearSecret from linear-verify.ts is a 5-minute in-memory cache, so warm Lambdas don't hit Secrets Manager on every rejection.
  • Mirrors agent/src/linear_reactions.py. Same GraphQL endpoint, same commentCreate / reactionCreate mutation shapes — kept consistent so when the Linear MCP server eventually exposes create_reaction (currently absent), both call sites can migrate together.
  • Non-Linear no-op. notifyLinearOnConcurrencyCap early-returns on channel_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 — wires LINEAR_API_TOKEN_SECRET_ARN into the webhook processor and grants read on LinearIntegration.apiTokenSecret.
  • agent.ts — same wiring for orchestrator.fn (separate construct; declared after LinearIntegration so done in the stack rather than as a prop).
  • Both grants verified at synth: LinearIntegrationWebhookProcessorFn and TaskOrchestratorOrchestratorFn both 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, GraphQL errors, network throw), Promise.allSettled partial-success semantics.
  • cdk/test/handlers/linear-webhook-processor.test.ts — extended with a user-visible feedback describe 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) — covers notifyLinearOnConcurrencyCap with withDurableExecution mocked. Asserts the linear path fires; api/webhook/slack paths no-op; missing metadata, missing env, undefined channel_metadata all 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.py which 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

  • No concurrency_limit_feedback event emitted yet — the orchestrator already emits admission_rejected with reason: 'concurrency_limit' to TaskEventsTable; that hasn't changed. The Linear feedback runs alongside, not replacing.
  • The processor's filter-rejected path 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_id is the only hook into the rejected task on the orchestrator side. This field is set by linear-webhook-processor.ts when the task is created and survives on the TaskRecord; no schema change.

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]>
@isadeks isadeks requested a review from a team as a code owner May 13, 2026 22:35
@isadeks isadeks marked this pull request as draft May 13, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant