feat(agentex): Linear gateway — invoke agents from Linear - #399
Conversation
A platform-side ingress that fronts a Linear agent app and routes each @mention / assignment to the resolved agent runtime — the Linear analog of the Slack gateway. - POST /linear/events: verify Linear-Signature (HMAC-SHA256 over the raw body) + webhookTimestamp freshness, dedup on the Linear-Delivery id, fast-ack + background dispatch. Auth-whitelisted (Linear can't present an SGP principal; the signature is the auth, verified in the use case). - Normalize AgentSessionEvent (created/prompted) and run the same selector cascade + task/create-or-event/send dispatch as the Slack gateway, keyed on the agent session (task_metadata.channel="linear"). - Reply via agentActivityCreate: a thought immediately (Linear's ~10s responsiveness window), then a terminal response/error. The Linear API token is minted via the client_credentials grant (inherently app-actor) and re-minted reactively on a 401 — no perishable token stored. - Remove a duplicate slack.router registration in app.py. Runs as a dedicated bot service account (its own identity), not a proxy for the invoking user — consistent with the Slack gateway's identity model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
✱ Stainless preview buildsThis PR will update the openapi python typescript
|
| seen = await self._seen_message_ids(acp.task_message_service, task.id) | ||
| await acp.handle_rpc_request( | ||
| method=AgentRPCMethod.EVENT_SEND, | ||
| params=SendEventRequestEntity(task_name=task_name, content=content), | ||
| agent_id=agent.id, | ||
| ) | ||
| return await self._collect_reply(acp.task_message_service, task.id, seen) |
There was a problem hiding this comment.
Concurrent Turns Mix Their Replies
If two distinct deliveries for one Linear session overlap, both turns snapshot and poll the same task without event-level correlation, causing either poller to collect the other prompt's agent messages and emit swapped, combined, or duplicated responses.
Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/use_cases/linear_gateway_use_case.py
Line: 475-481
Comment:
**Concurrent Turns Mix Their Replies**
If two distinct deliveries for one Linear session overlap, both turns snapshot and poll the same task without event-level correlation, causing either poller to collect the other prompt's agent messages and emit swapped, combined, or duplicated responses.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Valid but inherited — documented in 88283a5. Reply attribution is by message-id snapshot, the same interim design the Slack gateway ships with (see _collect_reply), so two prompts racing on the same session can interleave. Linear sessions are sequential in practice (prompt → wait → prompt), so the window is narrow. A real fix is event-correlated streaming, which is a cross-gateway change tracked as the shared follow-up — not something to diverge on in this PR. Added a note to the _collect_reply docstring.
- verify_signature FAILS CLOSED on an empty signing secret: an empty HMAC key is publicly known and forgeable, so an unconfigured LINEAR_WEBHOOK_SIGNING_SECRET must reject every delivery, not authenticate all of them. (P1 security) - _emit no longer logs the activity body when the token is missing — a response's body is agent output that shouldn't reach the logging pipeline; log the fact + length at WARNING (a dropped activity means LINEAR_CLIENT_ID/SECRET is unset). (P1 security) - Document the two intended, Slack-inherited behaviors flagged: a session is bound to its first-turn target (a follow-up's leading token is not a re-route), and reply collection is interim message-id snapshotting (concurrent same-session prompts can interleave; event-correlated streaming is the shared follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…401) Re-lands the Linear gateway from #399, which was merged (`4718eee`) then reverted by #400 (`bcf5a79`). ## Why it was reverted — and why that wasn't a code problem #399's revert was triggered by the **"Stainless build"** check, which failed in its *merge* step with: > `404 One or both branches not found: main, preview/mc/linear-gateway` — "This is a bug. Please report it at https://github.com/stainless-api/upload-openapi-spec-action/issues" That's a **Stainless SDK-generation / CI-infra failure** (the action couldn't find a preview branch and flags it as its own bug), not a defect in the gateway. **All 42 code checks on `4718eee` passed** — the full unit + sync/async integration matrix. So no code change was needed to re-land; this is the same commit (including the Greptile review fixes). If the Stainless build fails again the same way, it's an infra issue to re-run / raise with the Stainless integration owners, not a blocker on this code. ## What it is (unchanged from #399) Platform-side ingress fronting a Linear agent app; `POST /linear/events` verifies `Linear-Signature` + `webhookTimestamp`, dedups on `Linear-Delivery`, normalizes `AgentSessionEvent` (created/prompted), runs the Slack-gateway selector-cascade dispatch (`task_metadata.channel="linear"`), and replies via `agentActivityCreate` (thought → terminal response/error) using a `client_credentials` app-actor token (re-minted on 401). Runs as a dedicated bot service account. Includes the #399 review fixes: fail-closed on empty signing secret, and no response-body in logs on token failure. ## Testing 21 unit tests pass, ruff clean. Signed ingress + client_credentials token mint validated against live Linear earlier. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <h3>Greptile Summary</h3> The PR adds a signed Linear webhook gateway that deduplicates deliveries, dispatches Linear agent-session turns through AgentEx, and returns activities using an app-actor OAuth token. - Registers and documents `POST /linear/events`. - Adds signature freshness checks, delivery deduplication, target selection, bot identity propagation, task dispatch, reply polling, and Linear activity delivery. - Adds unit coverage for authentication, normalization, dispatch, identity handling, and token refresh. <details><summary><h3>Confidence Score: 4/5</h3></summary> The follow-up selector handling should be fixed before merging because it can silently alter user prompts in existing Linear sessions. Prompted events pass through the same selector-resolution path as initial events, so a leading word matching an agent or configuration is removed despite the session contract requiring follow-up content to remain intact. **Files Needing Attention:** agentex/src/domain/use_cases/linear_gateway_use_case.py </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/domain/use_cases/linear_gateway_use_case.py | Implements the complete Linear ingress and dispatch lifecycle, but applies first-turn selector stripping to prompted follow-ups. | | agentex/src/api/routes/linear.py | Adds the Linear webhook route and preserves the raw request body required for HMAC verification. | | agentex/src/api/middleware_utils.py | Whitelists Linear ingress so the gateway's webhook-signature authentication can run. | | agentex/src/api/app.py | Registers the new Linear router while removing a duplicate Slack router registration. | | agentex/openapi.yaml | Documents the new Linear webhook endpoint and its successful response. | | agentex/tests/unit/use_cases/test_linear_gateway_use_case.py | Covers core gateway behavior but does not exercise selector-like first words in prompted follow-ups. | </details> <details><summary><h3>Sequence Diagram</h3></summary> ```mermaid sequenceDiagram participant L as Linear participant API as POST /linear/events participant G as LinearGatewayUseCase participant R as Redis participant ACP as AgentEx ACP participant LA as Linear API L->>API: Signed AgentSessionEvent API->>G: Raw body, headers, payload G->>G: Verify signature and timestamp G->>R: SET delivery ID NX with TTL G-->>L: "200 {ok: true}" G->>LA: Emit thought activity G->>ACP: Resolve target and create/resume task ACP-->>G: Agent reply G->>LA: Emit terminal response or error ``` </details> <a href="https://app.greptile.com/api/ide/cursor?prompt=%23%23%23%20Issue%201%0Aagentex%2Fsrc%2Fdomain%2Fuse_cases%2Flinear_gateway_use_case.py%3A329%0A**Follow-up%20selector%20strips%20content**%0A%0AWhen%20a%20prompted%20follow-up%20starts%20with%20a%20registered%20agent%20or%20configuration%20name%2C%20%60_resolve_target%60%20treats%20that%20word%20as%20a%20selector%20and%20removes%20it%20before%20dispatching%20to%20the%20existing%20session%20task%2C%20causing%20the%20agent%20to%20receive%20a%20truncated%20user%20instruction%20even%20though%20selectors%20are%20intended%20to%20apply%20only%20on%20the%20first%20turn.%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&pr=401&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursorDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=6"><img alt="Fix All in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=6"></picture></a> <a href="https://app.greptile.com/ide/claude-code?prompt=%23%23%23%20Issue%201%0Aagentex%2Fsrc%2Fdomain%2Fuse_cases%2Flinear_gateway_use_case.py%3A329%0A**Follow-up%20selector%20strips%20content**%0A%0AWhen%20a%20prompted%20follow-up%20starts%20with%20a%20registered%20agent%20or%20configuration%20name%2C%20%60_resolve_target%60%20treats%20that%20word%20as%20a%20selector%20and%20removes%20it%20before%20dispatching%20to%20the%20existing%20session%20task%2C%20causing%20the%20agent%20to%20receive%20a%20truncated%20user%20instruction%20even%20though%20selectors%20are%20intended%20to%20apply%20only%20on%20the%20first%20turn.%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&repo=scaleapi%2Fscale-agentex&pr=401&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaudeDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=6"><img alt="Fix All in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=6"></picture></a> <a href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22mc%2Flinear-gateway-reland%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22mc%2Flinear-gateway-reland%22.%0A%0A%23%23%23%20Issue%201%0Aagentex%2Fsrc%2Fdomain%2Fuse_cases%2Flinear_gateway_use_case.py%3A329%0A**Follow-up%20selector%20strips%20content**%0A%0AWhen%20a%20prompted%20follow-up%20starts%20with%20a%20registered%20agent%20or%20configuration%20name%2C%20%60_resolve_target%60%20treats%20that%20word%20as%20a%20selector%20and%20removes%20it%20before%20dispatching%20to%20the%20existing%20session%20task%2C%20causing%20the%20agent%20to%20receive%20a%20truncated%20user%20instruction%20even%20though%20selectors%20are%20intended%20to%20apply%20only%20on%20the%20first%20turn.%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&repo=scaleapi%2Fscale-agentex&pr=401&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img alt="Fix All in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a> <details><summary>Prompt To Fix All With AI</summary> `````markdown ### Issue 1 agentex/src/domain/use_cases/linear_gateway_use_case.py:329 **Follow-up selector strips content** When a prompted follow-up starts with a registered agent or configuration name, `_resolve_target` treats that word as a selector and removes it before dispatching to the existing session task, causing the agent to receive a truncated user instruction even though selectors are intended to apply only on the first turn. --- For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. ````` </details> <sub>Reviews (1): Last reviewed commit: ["feat(agentex): Linear gateway — invoke a..."](e0c6d4a) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=51876870)</sub> > Greptile also left **1 inline comment** on this PR. <!-- /greptile_comment --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
What
A platform-side ingress that fronts a Linear agent app and routes each @mention / assignment to the resolved agent runtime — the Linear analog of the Slack gateway (#388 / #392 / #394).
How it works
POST /linear/events— verifies theLinear-Signature(HMAC-SHA256 over the raw body) + awebhookTimestampfreshness guard, dedups on theLinear-Deliveryid, acks fast (Linear's ~10s window), and runs the turn in the background. Auth-whitelisted like/slack— Linear can't present an SGP principal, so the signature is the auth, verified in the use case.AgentSessionEvent(created/prompted) → the same selector-cascade +task/create-or-event/senddispatch as the Slack gateway, keyed on the agent session (task_metadata.channel = "linear").agentActivityCreate— athoughtimmediately (a session is marked unresponsive without an activity within ~10s), then a terminalresponse/error. The Linear API token is minted via the OAuthclient_credentialsgrant (inherently app-actor) and re-minted reactively on a 401 — no perishable token is stored, only the static client id/secret in env.slack.routerregistration inapp.py.Identity
Runs as a dedicated bot service account (its own SGP identity), not a proxy for the invoking Linear user — consistent with the Slack gateway's identity model. Its comments/activities render as the app (
actor=app).Config (env / k8s-secret)
LINEAR_CLIENT_ID,LINEAR_CLIENT_SECRET,LINEAR_WEBHOOK_SIGNING_SECRET,LINEAR_GATEWAY_ACTING_BOT_API_KEY,LINEAR_GATEWAY_ACCOUNT_ID.Testing
agentActivityCreatetoken-mint + 401 re-mint. All pass, ruff clean.client_credentialstoken mint verified against the live Linear API (app-actor confirmed viaviewer).🤖 Generated with Claude Code
Greptile Summary
The PR adds a Linear webhook gateway that authenticates and deduplicates session events, dispatches them to Agentex runtimes under a dedicated bot identity, and returns activities through Linear’s API.
POST /linear/events.Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
/linear/eventsingress that preserves the raw request body for signature verification.Sequence Diagram
sequenceDiagram participant L as Linear participant R as POST /linear/events participant G as LinearGatewayUseCase participant D as Dedup Store participant A as Agentex Runtime participant API as Linear API L->>R: Signed AgentSessionEvent R->>G: Raw body, headers, payload G->>G: Verify HMAC and timestamp G->>D: Reserve delivery ID G-->>L: 200 acknowledgment G->>API: Create thought activity G->>A: Create/resume task and send event A-->>G: Agent response G->>API: Create response or error activityReviews (3): Last reviewed commit: "Merge branch 'main' into mc/linear-gatew..." | Re-trigger Greptile