Skip to content

Commit f38baad

Browse files
isadeksbgagentclaudekrokoko
authored
feat(linear): v1.1 polish — pre-container feedback, state-on-start, sweep, nits (#87)
* 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> * feat(linear): finish v1.1 polish — state-on-start + Alain #63 nits Wraps the v1.1 polish theme from PR #87. Five small additions, all agent-side or docs: State-on-start (the user-visible one): - prompt_builder._channel_prompt_addendum now instructs the agent to transition the originating Linear issue to `In Progress` (or `Todo` fallback) at agent-start, mirroring the existing `In Review` chain fired at PR-open. Closes the gap where the issue stayed at `Backlog` during real agent work — only the 👀 reaction and "🤖 Starting" comment signaled progress, while humans-using-Linear expect the state column to reflect "being worked." Skips if the issue is already in `In Progress` or any later state; doesn't loop on list_issue_statuses. Alain #63 review nits (4 small surgical changes): - linear_reactions.py: auth-failure circuit breaker. Track consecutive 401/403s; after 3 strikes, log ERROR once and short-circuit all later _graphql calls (return None) until the container restarts. Resets on any 2xx response. Replaces the prior behaviour where revoked tokens flooded CloudWatch with WARNs and wasted Linear API quota indefinitely. - pipeline.py: declare `linear_eyes_reaction_id: str | None = None` explicitly before the try block instead of relying on `locals().get("linear_eyes_reaction_id")` in the crash handler. Functionally identical; survives refactors and reads cleanly. - config.py::resolve_linear_api_token: narrow `except Exception` to `(BotoCoreError, ClientError)` from botocore.exceptions. Switch `print()` to `shell.log("WARN", ...)` so warnings join the structured log stream the rest of the agent uses. - LINEAR_SETUP_GUIDE.md + cli/src/commands/linear.ts: stop telling users to run `bgagent linear link <code>` when auto-link fails — the code generator is a v3 feature that doesn't ship in v1, so the suggestion was misleading. Replaced with explicit admin-assisted fallback (DynamoDB put-item with steps to find workspaceId, viewerId, Cognito sub) and a clear "this command exists but is non-functional in v1" note. Tests: 532 agent + 1268 cdk + 196 cli, all green. Deployed to backgroundagent-dev. Smoke-tested 👀-on-start (156ms, agent unblocked) in the prior commit; state-on-start smoke is the next manual step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(format): apply ruff format Whitespace-only changes flagged by CI's self-mutation guard. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(linear): address PR #87 must-fix review items - linear_reactions: guard auth-circuit globals with `_auth_state_lock` so the daemon sweep thread and the main thread can't race the read-modify-write on `_consecutive_auth_failures` / `_auth_circuit_open`. - linear_reactions: wrap the daemon sweep target in `_sweep_stale_reactions_safe` so an unexpected exception logs at ERROR instead of dying silently (stderr from a daemon thread doesn't reliably reach CloudWatch). - linear_reactions: only increment the sweep delete counter when `_graphql(_DELETE_MUTATION, ...)` actually returns a non-None response — previously the summary log overstated success. - config: hoist `import boto3` out of the catch-narrowed try/except so an `ImportError` (boto3 missing from the image) degrades to a WARN log instead of crashing the agent. - orchestrate-task: wrap `notifyLinearOnConcurrencyCap` in a defensive try/catch — durable-execution retries the entire admission-control step on throw, which would re-fire `failTask` + `emitTaskEvent` and produce duplicate events. - tests: 1 new throw-propagation test for `notifyLinearOnConcurrencyCap`, 3 new tests for `resolve_linear_api_token` (cached env, no-arn, ImportError fallback). Auto-reset fixture in `test_linear_reactions.py` now also resets the circuit-breaker globals between tests so future cases don't leak state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(linear): address PR #87 nice-to-have review items - linear_reactions: log a single DEBUG line when the auth circuit breaker short-circuits a call, so the path isn't zero-trace once open. - config: split the `(BotoCoreError, ClientError)` catch so `AccessDeniedException` logs at ERROR instead of WARN — IAM misconfig is persistent and should page someone, not blend into transient warnings. Also drop the personal name from the inline reference to the #63 review. - linear-webhook-processor: tighten `buildCreateTaskFailureMessage` param types to `number` / `string` (no `| undefined`) — the only caller passes `APIGatewayProxyResult` fields which are always defined. Removes dead fallback-to-`'unknown'` branches. - test_config: 2 new tests covering the split exception path (AccessDenied → ERROR; ResourceNotFound → WARN). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(linear): address PR #87 re-review must-fix items - linear-webhook-processor: extracted `safeReportIssueFailure` helper and routed all 5 bare `await reportIssueFailure(...)` call sites through it. The helper is uniformly non-throwing — wraps the call in try/catch to defend against a future signature refactor that could break the helper's `Promise.allSettled` never-throw contract. A synchronous throw would otherwise propagate, fail the Lambda, and trigger SQS retries on a poison message. - linear-webhook-processor: dropped the `!` non-null assertion on `process.env.LINEAR_API_TOKEN_SECRET_ARN` at module scope. The helper now guards on `!API_TOKEN_SECRET_ARN` and logs a single clear `Skipping Linear feedback: LINEAR_API_TOKEN_SECRET_ARN not set` diagnostic per skip — matches the orchestrator pattern in `notifyLinearOnConcurrencyCap`. - test_linear_reactions: new `TestAuthCircuitBreaker` class with 5 tests covering the previously-untested circuit: * 3 consecutive 401/403s open the circuit * Once open, calls short-circuit without hitting the network * A 2xx between failures resets the counter * Non-auth status (500) doesn't increment the counter * 401 and 403 are both treated as auth failures - test_linear-webhook-processor: 2 new tests assert `safeReportIssueFailure` swallows both synchronous throws and async rejections from the underlying helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(linear): address PR #87 re-review nice-to-have items - test_config: cover the BotoCoreError branch of `resolve_linear_api_token` with an `EndpointConnectionError` case. The PR-#87 split into ClientError + BotoCoreError branches previously had no test on the BotoCoreError path. - test_linear_reactions: new `test_sweep_preserves_just_posted_eyes_via_exclude_id` exercises the `exclude_id` filter — the existing sweep test never collided prior reaction ids with the newly posted one, so the branch was effectively dead code in tests. The new test plants the just- posted 👀 in the prior reactions list and asserts it survives the sweep while an older ❌ is deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(linear): annotate circuit breaker globals so ty doesn't narrow `ty check` infers `_consecutive_auth_failures = 0` as `Literal[0]` and `_auth_circuit_open = False` as `Literal[False]`, which then rejects the legitimate runtime flips (and the test fixture that resets them between cases). Adding explicit `int` / `bool` annotations widens the inferred type and fixes the CI typecheck failure introduced in `f4633be`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Alain Krok <alkrok@amazon.com>
1 parent be3207a commit f38baad

17 files changed

Lines changed: 1567 additions & 26 deletions

agent/src/config.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import uuid
66

77
from models import TaskConfig, TaskType
8+
from shell import log
89

910
AGENT_WORKSPACE = os.environ.get("AGENT_WORKSPACE", "/workspace")
1011

@@ -58,18 +59,35 @@ def resolve_linear_api_token() -> str:
5859
return ""
5960
try:
6061
import boto3
62+
from botocore.exceptions import BotoCoreError, ClientError
63+
except ImportError as e:
64+
# boto3 missing from the container image — degrade gracefully rather
65+
# than hard-crashing the agent. The Linear MCP will fail on first
66+
# call with a clear auth error.
67+
log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping")
68+
return ""
6169

70+
try:
6271
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
6372
client = boto3.client("secretsmanager", region_name=region)
6473
resp = client.get_secret_value(SecretId=secret_arn)
6574
token = resp.get("SecretString", "") or ""
6675
if token:
6776
os.environ["LINEAR_API_TOKEN"] = token
6877
return token
69-
except Exception as e:
78+
except ClientError as e:
79+
# Narrowed from a broader `except` per #63 review — broader catches
80+
# hid genuine bugs in the Secrets Manager call shape. AccessDenied
81+
# is logged at ERROR because it's a persistent IAM misconfig that
82+
# should page someone, not a transient blip.
83+
code = e.response.get("Error", {}).get("Code", "")
84+
severity = "ERROR" if code == "AccessDeniedException" else "WARN"
85+
log(severity, f"resolve_linear_api_token failed: {type(e).__name__}: {e}")
86+
return ""
87+
except BotoCoreError as e:
7088
# Never let a Secrets Manager outage crash the agent. The Linear MCP
7189
# will simply fail on first call with a clear auth error.
72-
print(f"[config] resolve_linear_api_token failed: {type(e).__name__}: {e}", flush=True)
90+
log("WARN", f"resolve_linear_api_token failed: {type(e).__name__}: {e}")
7391
return ""
7492

7593

agent/src/linear_reactions.py

Lines changed: 235 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from __future__ import annotations
2727

2828
import os
29+
import threading
30+
import time
2931
from typing import Any
3032

3133
import requests
@@ -60,6 +62,49 @@
6062
}
6163
""".strip()
6264

65+
#: Fetch reactions on an issue plus each reaction's emoji + owning user id —
66+
#: enough to filter by viewer (the API-token owner) and emoji on re-runs.
67+
_ISSUE_REACTIONS_QUERY = """
68+
query IssueReactions($issueId: String!) {
69+
issue(id: $issueId) {
70+
reactions {
71+
id
72+
emoji
73+
user { id }
74+
}
75+
}
76+
}
77+
""".strip()
78+
79+
#: Resolve the API-token owner so the sweep only deletes our own reactions
80+
#: and never touches reactions a human added.
81+
_VIEWER_QUERY = """
82+
query Viewer { viewer { id } }
83+
""".strip()
84+
85+
#: Reactions we own and want to clear before a fresh run.
86+
_BGAGENT_EMOJIS = frozenset({EMOJI_STARTED, EMOJI_SUCCESS, EMOJI_FAILURE})
87+
88+
#: Module-level cache of the API-token owner's id. Resolved once per
89+
#: container lifetime (Linear's `viewer { id }` is stable for the token).
90+
_viewer_id_cache: str | None = None
91+
92+
#: Auth-failure circuit breaker. Linear API tokens can be revoked mid-run;
93+
#: without a circuit breaker, every subsequent ``_graphql`` call retries
94+
#: (within its 5s timeout) and floods CloudWatch with WARNs while wasting
95+
#: Linear's quota. After ``_AUTH_FAILURE_THRESHOLD`` consecutive 401/403
96+
#: responses, ``_auth_circuit_open`` flips to True and all later calls
97+
#: short-circuit (return None) without hitting the network. A successful
98+
#: 2xx response resets the counter. The lock guards the read-modify-write
99+
#: against the daemon sweep thread.
100+
_AUTH_FAILURE_THRESHOLD = 3
101+
# Annotated explicitly so ty doesn't narrow the initial values to
102+
# `Literal[0]` / `Literal[False]` — that narrowing would reject the
103+
# legitimate flips below (and any test that resets them).
104+
_consecutive_auth_failures: int = 0
105+
_auth_circuit_open: bool = False
106+
_auth_state_lock = threading.Lock()
107+
63108

64109
def _enabled(channel_source: str, channel_metadata: dict[str, str] | None) -> str | None:
65110
"""Return the Linear issue id if reactions should fire, else None.
@@ -79,8 +124,19 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
79124
"""POST a GraphQL query. Return parsed data on success, None on any failure.
80125
81126
Swallows network / auth / schema errors with a WARN log — reactions are
82-
advisory and never gate the pipeline.
127+
advisory and never gate the pipeline. After
128+
``_AUTH_FAILURE_THRESHOLD`` consecutive auth failures (401/403), the
129+
module-level circuit breaker flips open and all later calls short-circuit
130+
without hitting the network. A successful 2xx response resets the counter.
83131
"""
132+
global _consecutive_auth_failures, _auth_circuit_open
133+
134+
with _auth_state_lock:
135+
circuit_open = _auth_circuit_open
136+
if circuit_open:
137+
log("DEBUG", "linear_reactions: auth circuit still open; short-circuiting call")
138+
return None
139+
84140
token = os.environ.get("LINEAR_API_TOKEN", "")
85141
if not token:
86142
log("WARN", "linear_reactions: LINEAR_API_TOKEN not set; skipping reaction")
@@ -100,10 +156,36 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
100156
log("WARN", f"linear_reactions: request failed ({type(e).__name__}): {e}")
101157
return None
102158

159+
if resp.status_code in (401, 403):
160+
with _auth_state_lock:
161+
_consecutive_auth_failures += 1
162+
opened = (
163+
_consecutive_auth_failures >= _AUTH_FAILURE_THRESHOLD and not _auth_circuit_open
164+
)
165+
if opened:
166+
_auth_circuit_open = True
167+
failures = _consecutive_auth_failures
168+
if opened:
169+
log(
170+
"ERROR",
171+
"linear_reactions: auth circuit OPEN after "
172+
f"{failures} consecutive {resp.status_code}s — "
173+
"API token likely revoked. Suppressing further Linear calls "
174+
"for this container.",
175+
)
176+
else:
177+
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear (auth)")
178+
return None
179+
103180
if resp.status_code != 200:
104181
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear")
105182
return None
106183

184+
# Successful 2xx — reset the auth failure counter so transient blips don't
185+
# accumulate toward the threshold.
186+
with _auth_state_lock:
187+
_consecutive_auth_failures = 0
188+
107189
body = resp.json() if resp.content else {}
108190
if body.get("errors"):
109191
log("WARN", f"linear_reactions: GraphQL errors: {body['errors']}")
@@ -112,18 +194,168 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
112194
return body.get("data") or {}
113195

114196

197+
def _get_viewer_id() -> str | None:
198+
"""Return the API-token owner's user id, cached for the container lifetime.
199+
200+
Used by ``_sweep_stale_reactions`` to scope deletes to bgagent-owned
201+
reactions only — without this filter, a re-run would also wipe any 👀 / ✅
202+
/ ❌ reactions a human user happened to add for unrelated reasons.
203+
"""
204+
global _viewer_id_cache
205+
if _viewer_id_cache:
206+
return _viewer_id_cache
207+
data = _graphql(_VIEWER_QUERY, {})
208+
if not data:
209+
return None
210+
viewer_id = (data.get("viewer") or {}).get("id")
211+
if isinstance(viewer_id, str) and viewer_id:
212+
_viewer_id_cache = viewer_id
213+
return viewer_id
214+
return None
215+
216+
217+
def _sweep_stale_reactions_safe(issue_id: str, exclude_id: str | None = None) -> None:
218+
"""Top-level wrapper for the sweep daemon thread.
219+
220+
Catches everything so an unexpected ``TypeError`` / ``AttributeError``
221+
inside ``_sweep_stale_reactions`` doesn't kill the thread silently —
222+
stderr from a daemon thread may not reach CloudWatch in containerized
223+
environments.
224+
"""
225+
try:
226+
_sweep_stale_reactions(issue_id, exclude_id=exclude_id)
227+
except Exception as e:
228+
log(
229+
"ERROR",
230+
f"linear_reactions: sweep thread crashed ({type(e).__name__}): {e}",
231+
)
232+
233+
234+
def _sweep_stale_reactions(issue_id: str, exclude_id: str | None = None) -> None:
235+
"""Delete bgagent-owned 👀/✅/❌ reactions on the issue.
236+
237+
Called from ``react_task_started`` *after* the new 👀 is posted, so
238+
re-runs (label removed and re-applied; or pre-container ❌ from the
239+
orchestrator/processor followed by a successful retry) don't accumulate
240+
stale terminal markers next to the new 👀. Running after the post
241+
means the user-visible 👀 lands fast even if the sweep's first call
242+
hits cold-connection latency on Linear's API.
243+
244+
The just-posted 👀 must not be deleted by the sweep — pass its id as
245+
``exclude_id`` so the filter skips it.
246+
247+
Best-effort: any failure (viewer fetch, reactions query, individual
248+
reactionDelete) is logged and swallowed — sweep is post-👀 cleanup
249+
and never gates the pipeline.
250+
"""
251+
sweep_start = time.monotonic()
252+
viewer_id = _get_viewer_id()
253+
if not viewer_id:
254+
log("WARN", "linear_reactions: skipping sweep — could not resolve viewer id")
255+
return
256+
257+
viewer_ms = int((time.monotonic() - sweep_start) * 1000)
258+
reactions_start = time.monotonic()
259+
data = _graphql(_ISSUE_REACTIONS_QUERY, {"issueId": issue_id})
260+
reactions_ms = int((time.monotonic() - reactions_start) * 1000)
261+
if not data:
262+
log(
263+
"TASK",
264+
"linear_reactions: sweep skipped (reactions query failed) "
265+
f"viewer={viewer_ms}ms reactions={reactions_ms}ms",
266+
)
267+
return
268+
269+
reactions = (data.get("issue") or {}).get("reactions") or []
270+
deletes = 0
271+
deletes_start = time.monotonic()
272+
for r in reactions:
273+
if not isinstance(r, dict):
274+
continue
275+
emoji = r.get("emoji")
276+
if emoji not in _BGAGENT_EMOJIS:
277+
continue
278+
user = r.get("user") or {}
279+
if user.get("id") != viewer_id:
280+
continue
281+
rid = r.get("id")
282+
if not rid:
283+
continue
284+
if exclude_id is not None and rid == exclude_id:
285+
# The 👀 we just posted — skip, it's the new marker.
286+
continue
287+
if _graphql(_DELETE_MUTATION, {"id": rid}) is not None:
288+
deletes += 1
289+
deletes_ms = int((time.monotonic() - deletes_start) * 1000)
290+
total_ms = int((time.monotonic() - sweep_start) * 1000)
291+
log(
292+
"TASK",
293+
f"linear_reactions: sweep done total={total_ms}ms viewer={viewer_ms}ms "
294+
f"reactions={reactions_ms}ms deletes={deletes}({deletes_ms}ms)",
295+
)
296+
297+
115298
def react_task_started(
116299
channel_source: str,
117300
channel_metadata: dict[str, str] | None,
118301
) -> str | None:
119-
"""Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op)."""
302+
"""Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op).
303+
304+
Order matters: the 👀 is posted *first*, then we sweep any stale
305+
bgagent-owned 👀/✅/❌ from prior runs (excluding the one we just
306+
posted). This keeps the user-visible signal fast — if Linear's API
307+
is slow on a cold connection, the 5s timeout falls on a sweep call
308+
and nobody waits, instead of falling on the 👀 post and gating it.
309+
310+
Sweep is best-effort; failure leaves stale terminal markers next to
311+
the new 👀 (the visual-duplication bug we set out to fix), but the
312+
pipeline proceeds unaffected.
313+
"""
120314
issue_id = _enabled(channel_source, channel_metadata)
121315
if not issue_id:
122316
return None
317+
log("TASK", f"linear_reactions: react_task_started ENTER issue_id={issue_id}")
318+
started_at = time.monotonic()
319+
320+
# Post 👀 first — this is the user-visible signal.
321+
create_start = time.monotonic()
123322
data = _graphql(_CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_STARTED})
323+
create_ms = int((time.monotonic() - create_start) * 1000)
124324
if not data:
325+
total_ms = int((time.monotonic() - started_at) * 1000)
326+
log(
327+
"WARN",
328+
"linear_reactions: react_task_started EXIT (👀 failed) "
329+
f"total={total_ms}ms create={create_ms}ms",
330+
)
125331
return None
126-
return (data.get("reactionCreate") or {}).get("reaction", {}).get("id")
332+
rid = (data.get("reactionCreate") or {}).get("reaction", {}).get("id")
333+
eyes_ms = int((time.monotonic() - started_at) * 1000)
334+
log(
335+
"TASK",
336+
f"linear_reactions: 👀 posted reaction_id={rid} create={create_ms}ms "
337+
f"(eyes-visible at +{eyes_ms}ms)",
338+
)
339+
340+
# Sweep prior bgagent reactions in a background thread so the agent
341+
# pipeline doesn't block on Linear API latency. Daemon=True so the
342+
# thread doesn't keep the container alive past the agent's terminal
343+
# status. The sweep filters out the just-posted reaction id so it
344+
# never deletes itself.
345+
threading.Thread(
346+
target=_sweep_stale_reactions_safe,
347+
args=(issue_id,),
348+
kwargs={"exclude_id": rid},
349+
daemon=True,
350+
name="linear-reactions-sweep",
351+
).start()
352+
353+
log(
354+
"TASK",
355+
f"linear_reactions: react_task_started EXIT (sweep dispatched) "
356+
f"total={eyes_ms}ms create={create_ms}ms reaction_id={rid}",
357+
)
358+
return rid
127359

128360

129361
def react_task_finished(

agent/src/pipeline.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
338338
)
339339

340340
trajectory.set_truncation_callback(_on_trace_truncated)
341+
# Declared up-front so the crash handler at the bottom of this `try`
342+
# can reference it via a normal name rather than ``locals().get(...)``
343+
# — survives refactors and reads cleanly. Stays None until the Linear
344+
# `react_task_started` call assigns the actual reaction id.
345+
linear_eyes_reaction_id: str | None = None
341346
try:
342347
# Context hydration
343348
with task_span("task.context_hydration"):
@@ -724,13 +729,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
724729
task_state.write_terminal(config.task_id, "FAILED", crash_result.model_dump())
725730
# Best-effort ❌ on the Linear issue so the stale 👀 doesn't linger.
726731
# No-op for non-Linear tasks; network/GraphQL failures are swallowed.
727-
# `linear_eyes_reaction_id` may be unbound if we crashed before the
728-
# start-reaction call — guarded with locals() to stay safe.
732+
# `linear_eyes_reaction_id` is initialized to None at the top of
733+
# this try block, so it's always bound here even if we crashed
734+
# before the start-reaction call assigned a real id.
729735
react_task_finished(
730736
config.channel_source,
731737
config.channel_metadata,
732738
success=False,
733-
started_reaction_id=locals().get("linear_eyes_reaction_id"),
739+
started_reaction_id=linear_eyes_reaction_id,
734740
)
735741
raise
736742

agent/src/prompt_builder.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,23 @@ def _channel_prompt_addendum(config: TaskConfig) -> str:
9898
return (
9999
"\n\n## Linear issue progress updates (REQUIRED)\n\n"
100100
f"This task was submitted from Linear issue{issue_ref}. The Linear MCP "
101-
"server is loaded. You MUST perform these three updates; they are part "
102-
"of the task contract, not optional:\n\n"
101+
"server is loaded. You MUST perform these updates; they are part of "
102+
"the task contract, not optional:\n\n"
103103
"1. **At start** — call `mcp__linear-server__save_comment` with a short "
104-
'"🤖 Starting on this issue…" message.\n'
104+
'"🤖 Starting on this issue…" message, then call '
105+
"`mcp__linear-server__save_issue` to transition the issue state. Use "
106+
"`mcp__linear-server__list_issue_statuses` first if you don't already "
107+
"know the state ids; pick the one named `In Progress` (fall back to "
108+
"`Todo` if that state doesn't exist). If the issue is already in "
109+
"`In Progress` or any later state (`In Review`, `Done`), skip the "
110+
"transition. If neither exists, skip — the comment alone is enough. "
111+
"Do not invent state names or loop on `list_issue_statuses`.\n"
105112
"2. **When you open the PR** — call `mcp__linear-server__save_comment` "
106113
"with the PR URL, then call `mcp__linear-server__save_issue` to "
107-
"transition the issue state. Use `mcp__linear-server__list_issue_statuses` "
108-
"first if you don't already know the state ids; pick the one named "
109-
"`In Review` (fall back to `In Progress` if that state doesn't exist). "
110-
"If neither exists, skip the state transition — the PR comment alone "
111-
"is enough. Do not invent state names or loop on `list_issue_statuses`.\n"
114+
"transition the issue state to `In Review` (fall back to `In Progress` "
115+
"if that state doesn't exist). If neither exists, skip the state "
116+
"transition — the PR comment alone is enough. Do not invent state "
117+
"names or loop on `list_issue_statuses`.\n"
112118
"3. **On completion or failure** — call `mcp__linear-server__save_comment` "
113119
"with the final status (succeeded / failed + short reason).\n\n"
114120
"Keep comments concise. Do not mirror the full agent transcript back to "

0 commit comments

Comments
 (0)