Skip to content

Commit 07bcc5b

Browse files
bgagentclaude
andcommitted
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>
1 parent f541481 commit 07bcc5b

8 files changed

Lines changed: 499 additions & 26 deletions

File tree

agent/src/config.py

Lines changed: 7 additions & 3 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,6 +59,7 @@ def resolve_linear_api_token() -> str:
5859
return ""
5960
try:
6061
import boto3
62+
from botocore.exceptions import BotoCoreError, ClientError
6163

6264
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
6365
client = boto3.client("secretsmanager", region_name=region)
@@ -66,10 +68,12 @@ def resolve_linear_api_token() -> str:
6668
if token:
6769
os.environ["LINEAR_API_TOKEN"] = token
6870
return token
69-
except Exception as e:
71+
except (BotoCoreError, ClientError) as e:
7072
# Never let a Secrets Manager outage crash the agent. The Linear MCP
71-
# 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)
73+
# will simply fail on first call with a clear auth error. Narrowed
74+
# to botocore exceptions per Alain's #63 review — broader `except`
75+
# hid genuine bugs in the Secrets Manager call shape.
76+
log("WARN", f"resolve_linear_api_token failed: {type(e).__name__}: {e}")
7377
return ""
7478

7579

agent/src/linear_reactions.py

Lines changed: 203 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,44 @@
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.
99+
_AUTH_FAILURE_THRESHOLD = 3
100+
_consecutive_auth_failures = 0
101+
_auth_circuit_open = False
102+
63103

64104
def _enabled(channel_source: str, channel_metadata: dict[str, str] | None) -> str | None:
65105
"""Return the Linear issue id if reactions should fire, else None.
@@ -79,8 +119,16 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
79119
"""POST a GraphQL query. Return parsed data on success, None on any failure.
80120
81121
Swallows network / auth / schema errors with a WARN log — reactions are
82-
advisory and never gate the pipeline.
122+
advisory and never gate the pipeline. After
123+
``_AUTH_FAILURE_THRESHOLD`` consecutive auth failures (401/403), the
124+
module-level circuit breaker flips open and all later calls short-circuit
125+
without hitting the network. A successful 2xx response resets the counter.
83126
"""
127+
global _consecutive_auth_failures, _auth_circuit_open
128+
129+
if _auth_circuit_open:
130+
return None
131+
84132
token = os.environ.get("LINEAR_API_TOKEN", "")
85133
if not token:
86134
log("WARN", "linear_reactions: LINEAR_API_TOKEN not set; skipping reaction")
@@ -100,10 +148,29 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
100148
log("WARN", f"linear_reactions: request failed ({type(e).__name__}): {e}")
101149
return None
102150

151+
if resp.status_code in (401, 403):
152+
_consecutive_auth_failures += 1
153+
if _consecutive_auth_failures >= _AUTH_FAILURE_THRESHOLD and not _auth_circuit_open:
154+
_auth_circuit_open = True
155+
log(
156+
"ERROR",
157+
"linear_reactions: auth circuit OPEN after "
158+
f"{_consecutive_auth_failures} consecutive {resp.status_code}s — "
159+
"API token likely revoked. Suppressing further Linear calls "
160+
"for this container.",
161+
)
162+
else:
163+
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear (auth)")
164+
return None
165+
103166
if resp.status_code != 200:
104167
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear")
105168
return None
106169

170+
# Successful 2xx — reset the auth failure counter so transient blips don't
171+
# accumulate toward the threshold.
172+
_consecutive_auth_failures = 0
173+
107174
body = resp.json() if resp.content else {}
108175
if body.get("errors"):
109176
log("WARN", f"linear_reactions: GraphQL errors: {body['errors']}")
@@ -112,18 +179,151 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
112179
return body.get("data") or {}
113180

114181

182+
def _get_viewer_id() -> str | None:
183+
"""Return the API-token owner's user id, cached for the container lifetime.
184+
185+
Used by ``_sweep_stale_reactions`` to scope deletes to bgagent-owned
186+
reactions only — without this filter, a re-run would also wipe any 👀 / ✅
187+
/ ❌ reactions a human user happened to add for unrelated reasons.
188+
"""
189+
global _viewer_id_cache
190+
if _viewer_id_cache:
191+
return _viewer_id_cache
192+
data = _graphql(_VIEWER_QUERY, {})
193+
if not data:
194+
return None
195+
viewer_id = (data.get("viewer") or {}).get("id")
196+
if isinstance(viewer_id, str) and viewer_id:
197+
_viewer_id_cache = viewer_id
198+
return viewer_id
199+
return None
200+
201+
202+
def _sweep_stale_reactions(issue_id: str, exclude_id: str | None = None) -> None:
203+
"""Delete bgagent-owned 👀/✅/❌ reactions on the issue.
204+
205+
Called from ``react_task_started`` *after* the new 👀 is posted, so
206+
re-runs (label removed and re-applied; or pre-container ❌ from the
207+
orchestrator/processor followed by a successful retry) don't accumulate
208+
stale terminal markers next to the new 👀. Running after the post
209+
means the user-visible 👀 lands fast even if the sweep's first call
210+
hits cold-connection latency on Linear's API.
211+
212+
The just-posted 👀 must not be deleted by the sweep — pass its id as
213+
``exclude_id`` so the filter skips it.
214+
215+
Best-effort: any failure (viewer fetch, reactions query, individual
216+
reactionDelete) is logged and swallowed — sweep is post-👀 cleanup
217+
and never gates the pipeline.
218+
"""
219+
sweep_start = time.monotonic()
220+
viewer_id = _get_viewer_id()
221+
if not viewer_id:
222+
log("WARN", "linear_reactions: skipping sweep — could not resolve viewer id")
223+
return
224+
225+
viewer_ms = int((time.monotonic() - sweep_start) * 1000)
226+
reactions_start = time.monotonic()
227+
data = _graphql(_ISSUE_REACTIONS_QUERY, {"issueId": issue_id})
228+
reactions_ms = int((time.monotonic() - reactions_start) * 1000)
229+
if not data:
230+
log(
231+
"TASK",
232+
"linear_reactions: sweep skipped (reactions query failed) "
233+
f"viewer={viewer_ms}ms reactions={reactions_ms}ms",
234+
)
235+
return
236+
237+
reactions = ((data.get("issue") or {}).get("reactions") or [])
238+
deletes = 0
239+
deletes_start = time.monotonic()
240+
for r in reactions:
241+
if not isinstance(r, dict):
242+
continue
243+
emoji = r.get("emoji")
244+
if emoji not in _BGAGENT_EMOJIS:
245+
continue
246+
user = r.get("user") or {}
247+
if user.get("id") != viewer_id:
248+
continue
249+
rid = r.get("id")
250+
if not rid:
251+
continue
252+
if exclude_id is not None and rid == exclude_id:
253+
# The 👀 we just posted — skip, it's the new marker.
254+
continue
255+
_graphql(_DELETE_MUTATION, {"id": rid})
256+
deletes += 1
257+
deletes_ms = int((time.monotonic() - deletes_start) * 1000)
258+
total_ms = int((time.monotonic() - sweep_start) * 1000)
259+
log(
260+
"TASK",
261+
f"linear_reactions: sweep done total={total_ms}ms viewer={viewer_ms}ms "
262+
f"reactions={reactions_ms}ms deletes={deletes}({deletes_ms}ms)",
263+
)
264+
265+
115266
def react_task_started(
116267
channel_source: str,
117268
channel_metadata: dict[str, str] | None,
118269
) -> str | None:
119-
"""Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op)."""
270+
"""Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op).
271+
272+
Order matters: the 👀 is posted *first*, then we sweep any stale
273+
bgagent-owned 👀/✅/❌ from prior runs (excluding the one we just
274+
posted). This keeps the user-visible signal fast — if Linear's API
275+
is slow on a cold connection, the 5s timeout falls on a sweep call
276+
and nobody waits, instead of falling on the 👀 post and gating it.
277+
278+
Sweep is best-effort; failure leaves stale terminal markers next to
279+
the new 👀 (the visual-duplication bug we set out to fix), but the
280+
pipeline proceeds unaffected.
281+
"""
120282
issue_id = _enabled(channel_source, channel_metadata)
121283
if not issue_id:
122284
return None
285+
log("TASK", f"linear_reactions: react_task_started ENTER issue_id={issue_id}")
286+
started_at = time.monotonic()
287+
288+
# Post 👀 first — this is the user-visible signal.
289+
create_start = time.monotonic()
123290
data = _graphql(_CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_STARTED})
291+
create_ms = int((time.monotonic() - create_start) * 1000)
124292
if not data:
293+
total_ms = int((time.monotonic() - started_at) * 1000)
294+
log(
295+
"WARN",
296+
"linear_reactions: react_task_started EXIT (👀 failed) "
297+
f"total={total_ms}ms create={create_ms}ms",
298+
)
125299
return None
126-
return (data.get("reactionCreate") or {}).get("reaction", {}).get("id")
300+
rid = (data.get("reactionCreate") or {}).get("reaction", {}).get("id")
301+
eyes_ms = int((time.monotonic() - started_at) * 1000)
302+
log(
303+
"TASK",
304+
f"linear_reactions: 👀 posted reaction_id={rid} create={create_ms}ms "
305+
f"(eyes-visible at +{eyes_ms}ms)",
306+
)
307+
308+
# Sweep prior bgagent reactions in a background thread so the agent
309+
# pipeline doesn't block on Linear API latency. Daemon=True so the
310+
# thread doesn't keep the container alive past the agent's terminal
311+
# status. The sweep filters out the just-posted reaction id so it
312+
# never deletes itself.
313+
threading.Thread(
314+
target=_sweep_stale_reactions,
315+
args=(issue_id,),
316+
kwargs={"exclude_id": rid},
317+
daemon=True,
318+
name="linear-reactions-sweep",
319+
).start()
320+
321+
log(
322+
"TASK",
323+
f"linear_reactions: react_task_started EXIT (sweep dispatched) "
324+
f"total={eyes_ms}ms create={create_ms}ms reaction_id={rid}",
325+
)
326+
return rid
127327

128328

129329
def react_task_finished(

agent/src/pipeline.py

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

332332
trajectory.set_truncation_callback(_on_trace_truncated)
333+
# Declared up-front so the crash handler at the bottom of this `try`
334+
# can reference it via a normal name rather than ``locals().get(...)``
335+
# — survives refactors and reads cleanly. Stays None until the Linear
336+
# `react_task_started` call assigns the actual reaction id.
337+
linear_eyes_reaction_id: str | None = None
333338
try:
334339
# Context hydration
335340
with task_span("task.context_hydration"):
@@ -710,13 +715,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
710715
task_state.write_terminal(config.task_id, "FAILED", crash_result.model_dump())
711716
# Best-effort ❌ on the Linear issue so the stale 👀 doesn't linger.
712717
# No-op for non-Linear tasks; network/GraphQL failures are swallowed.
713-
# `linear_eyes_reaction_id` may be unbound if we crashed before the
714-
# start-reaction call — guarded with locals() to stay safe.
718+
# `linear_eyes_reaction_id` is initialized to None at the top of
719+
# this try block, so it's always bound here even if we crashed
720+
# before the start-reaction call assigned a real id.
715721
react_task_finished(
716722
config.channel_source,
717723
config.channel_metadata,
718724
success=False,
719-
started_reaction_id=locals().get("linear_eyes_reaction_id"),
725+
started_reaction_id=linear_eyes_reaction_id,
720726
)
721727
raise
722728

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)