feat(linear): v1.1 polish — pre-container feedback, state-on-start, sweep, nits#87
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) <noreply@anthropic.com>
nits Wraps the v1.1 polish theme from PR aws-samples#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 aws-samples#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>
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>
Code Review SummaryGreat PR — the architecture is sound, test coverage is thorough (28 new tests), and the "best-effort, never gate the pipeline" philosophy is consistently applied. A few items to address before merge: Must fix1. Thread-safety of circuit breaker globals (
→ Add a 2. If → Wrap in a defensive try-catch: try {
await notifyLinearOnConcurrencyCap(task);
} catch (feedbackErr) {
logger.warn('Linear concurrency-cap feedback failed (non-fatal)', { error: ... });
}Should fix3.
→ Add 4. Daemon thread crash silence ( If → Wrap the thread target in a top-level try-except that logs via 5. Sweep delete counter inaccuracy (
→ Nice to have (non-blocking)
Overall: solid work. The two "must fix" items are small changes that prevent real (if unlikely) failure modes. Happy to re-review once addressed. |
- 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>
- 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 aws-samples#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>
|
Thanks for the thorough review @krokoko. All 9 items addressed across two commits — split must/should-fix from nice-to-have so the load-bearing changes are isolated. Must-fix (
|
krokoko
left a comment
There was a problem hiding this comment.
Re-review of PR #87
First review items: all addressed ✓
All 9 items from the initial review (5 must/should-fix + 4 nice-to-have) are correctly addressed in dd5474e and 420fc11:
- Thread-safety lock on circuit breaker globals — correctly implemented with
_auth_state_lock - Defensive try/catch around
notifyLinearOnConcurrencyCap— in place, with a test proving the catch is load-bearing - ImportError regression — hoisted into its own try/except before the narrowed
(BotoCoreError, ClientError)catch - Daemon thread crash wrapper —
_sweep_stale_reactions_safecatchesExceptionand logs at ERROR - Sweep delete counter — gated on
_graphql(...) is not None - All 4 nice-to-haves (DEBUG log, AccessDenied → ERROR, param tightening, name scrub) — done
New findings
Should fix
1. Missing defensive try/catch around reportIssueFailure in the webhook processor (linear-webhook-processor.ts)
The orchestrator correctly wraps notifyLinearOnConcurrencyCap in try/catch as belt-and-suspenders. The 5 bare await reportIssueFailure(...) calls in the webhook processor have no such defense. While Promise.allSettled structurally guarantees the function cannot reject today, a synchronous TypeError before the Promise.allSettled is constructed (e.g. if the function signature is refactored) would propagate, causing Lambda failure and SQS retries.
Suggestion: extract a local wrapper at the top of the file and call it at all 5 sites:
async function safeReportIssueFailure(issueId: string, message: string): Promise<void> {
try {
await reportIssueFailure(API_TOKEN_SECRET_ARN, issueId, message);
} catch (err) {
logger.warn('Linear feedback failed (non-fatal)', {
issue_id: issueId,
error: err instanceof Error ? err.message : String(err),
});
}
}2. process.env.LINEAR_API_TOKEN_SECRET_ARN! non-null assertion without runtime guard (linear-webhook-processor.ts:30)
The orchestrator path correctly checks if (!secretArn) and logs a warning before returning. The processor uses a ! assertion at module scope — if the env var is missing due to a deploy misconfiguration, every feedback call silently fails with a cryptic WARN from resolveToken ("could not resolve API token") instead of a clear one-time diagnostic.
Suggestion: match the orchestrator pattern — drop the !, add a guard at usage or a startup-time log.
3. Auth circuit breaker has zero tests (test_linear_reactions.py)
The circuit breaker is ~40 lines of thread-safe logic (_AUTH_FAILURE_THRESHOLD, _consecutive_auth_failures, _auth_circuit_open, _auth_state_lock) with no test coverage. Missing scenarios:
- 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 errors (500) don't reset the counter
The _reset_module_state fixture already handles cleanup, so adding a TestAuthCircuitBreaker class should be straightforward.
Nice to have
4. BotoCoreError handler path untested (test_config.py) — The PR narrowed except Exception to two separate handlers but only ClientError paths have tests. One test for BotoCoreError (e.g. EndpointConnectionError) would close this.
5. exclude_id filter in sweep is never exercised (test_linear_reactions.py) — In test_sweep_deletes_only_viewer_owned_bgagent_emojis, prior reaction IDs never collide with the new reaction ID, so the exclude_id branch is dead code in tests. Add a prior reaction whose ID matches the newly posted one and verify it's preserved.
Out of scope (pre-existing, noting for tracking)
6. resp.json() in linear_reactions.py::_graphql can throw JSONDecodeError — The body = resp.json() if resp.content else {} line is outside any try/except. If Linear returns a 200 with non-JSON body (HTML maintenance page), it raises ValueError. The new sweep code increases the probability of hitting this (3+ _graphql calls per react_task_started). The _sweep_stale_reactions_safe wrapper catches it for the daemon thread, but the main-thread path would propagate. Worth a separate fix.
Summary table
| # | Finding | Severity |
|---|---|---|
| 1 | Bare await reportIssueFailure(...) — 5 sites without try/catch |
Should fix |
| 2 | ! non-null assertion without runtime guard |
Should fix |
| 3 | Auth circuit breaker has zero tests | Should fix |
| 4 | BotoCoreError path untested |
Nice to have |
| 5 | exclude_id filter untested |
Nice to have |
| 6 | Pre-existing resp.json() crash risk |
Out of scope |
Items 1–3 are worth addressing before merge. 4–5 would improve confidence but aren't blocking. 6 should be tracked separately.
Positive notes
Promise.allSettledinreportIssueFailureis the right construct — structurally guarantees the never-throw contract- Thread safety is correctly implemented — single lock, no nested acquisitions, no deadlock risk
- Test quality is strong:
_join_sweep_thread()for daemon thread sync, comprehensive positive and negative assertions on feedback paths, and the test provingreportIssueFailurerejection propagates (validating the orchestrator's catch is load-bearing) is particularly thoughtful - The "best-effort, never gate the pipeline" philosophy is consistently applied across all layers
- CDK wiring (IAM grants + env vars) is correct in both
linear-integration.tsandagent.ts - Docs sync is correct
- 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_config: cover the BotoCoreError branch of `resolve_linear_api_token` with an `EndpointConnectionError` case. The PR-aws-samples#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>
`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>
|
Thanks for the second pass @krokoko. All 5 actionable findings addressed across two commits, with the out-of-scope item parked for a follow-up. Ty narrowing pre-existing in Should-fix (
|
Migrates the agent runtime's Linear personal API token resolution from
AWS Secrets Manager to AWS Bedrock AgentCore Identity. This is the
"validate Identity SDK" step of the v2 plan; Phase 2.0b will swap the
API key for OAuth and converge Linear MCP onto AgentCore Gateway in
one cutover.
Per Alain's guidance: "start by using api key, if it works, switch to
oauth. you will setup an outbound auth for your server using agentcore
identity. that identity can be (AC identity is like a wrapper around
secrets manager) api key or oauth."
Lambdas (orchestrator + processor) intentionally keep using Secrets
Manager via the existing `LinearApiTokenSecret` for now. The Python
`bedrock_agentcore` SDK has no Node.js equivalent — Lambda migration
requires `@aws-sdk/client-bedrock-agentcore` raw API calls and folds
into 2.0b's bigger refactor. End-state of 2.0a: agent reads from
Identity, Lambdas read from Secrets Manager, both pointing at the same
underlying token value (admin populates both).
`agent/src/config.py::resolve_linear_api_token`:
- Drops boto3 SecretsManager fetch + `LINEAR_API_TOKEN_SECRET_ARN` env.
- Reads new env `LINEAR_API_KEY_PROVIDER_NAME` (provider name in
Identity vault).
- Calls `IdentityClient.get_api_key()` with the workload access token
auto-injected into `BedrockAgentCoreContext` by AgentCore Runtime
(verified by reading the SDK's `auth.py` decorator implementation —
no manual workload-identity mint needed inside the runtime).
- Caches the resolved token in `LINEAR_API_TOKEN` so downstream
consumers stay unchanged: `channel_mcp.py`'s `${LINEAR_API_TOKEN}`
placeholder in `.mcp.json` and `linear_reactions.py`'s GraphQL
Authorization header.
Preserves PR aws-samples#87's nice-to-have improvements:
- `ImportError` graceful fallback (now for `bedrock_agentcore` instead
of `boto3`) — degrade with WARN, don't crash the agent.
- `AccessDeniedException` and `ResourceNotFoundException` logged at
ERROR severity (persistent IAM/config bugs that should page).
Other ClientErrors stay at WARN (transient throttle/network).
`agent/pyproject.toml`: adds `bedrock-agentcore==1.9.1` dep.
`cdk/src/stacks/agent.ts`:
- On the AgentCore runtime: drops `linearIntegration.apiTokenSecret.
grantRead(runtime)` and the `LINEAR_API_TOKEN_SECRET_ARN` env-var
override. Adds `LINEAR_API_KEY_PROVIDER_NAME` env (hardcoded
`'linear-api-key'` for now; can parametrize later via context if
multi-environment naming is needed) and IAM permissions for
`bedrock-agentcore:GetResourceApiKey` and
`bedrock-agentcore:GetWorkloadAccessToken`.
- Lambdas (orchestrator + processor) untouched — they still grant on
the Linear secret and read from Secrets Manager.
- Resource scope on the new IAM is `*` for now; AgentCore Identity ARN
format isn't fully standardized in public docs as of 2026-05-15.
Tighten in 2.0b when OAuth migration documents the canonical
resource shape.
`docs/guides/LINEAR_SETUP_GUIDE.md`: adds Step 4.5 documenting the
one-time `agentcore add credential --type api-key --name linear-api-key`
admin command users must run alongside the existing `bgagent linear
setup` wizard. Notes that Lambdas keep Secrets Manager temporarily and
2.0b will retire the dual-store setup. Starlight mirror synced.
`agent/tests/test_config.py::TestResolveLinearApiToken` — 10 tests
covering: cached env var fast-path; missing provider name; missing
region; workload token absent (outside runtime); happy path with
env-var side-effect; botocore error swallowed with WARN; SDK returns
None defensively; ImportError fallback; AccessDeniedException → ERROR
severity; ResourceNotFoundException → ERROR severity.
542 agent / 1271 cdk / 196 cli, all green. Lint + typecheck clean.
CDK synth clean.
`bedrock_agentcore` SDK confirmed working in our runtime image (verified
in `node_modules` post-install). The `BedrockAgentCoreContext` workload
token auto-injection is documented behaviour for code running inside
AgentCore Runtime — verified by reading the SDK's `@requires_api_key`
decorator implementation, which uses the same context lookup we use
here.
Stacked on PR aws-samples#87 (`feat/linear-processor-feedback`). Will conflict on
`config.py` and `test_config.py` if aws-samples#87 needs further rework before
merge — happy to rebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Wraps the v1.1 polish theme for the Linear integration, addressing the silent-drop gaps and PR #63 review recommendations in a single PR. Six rejection paths now produce user-visible feedback; the issue state transitions on agent-start (not just at PR-open); a stale-reaction sweep keeps re-runs clean; and four small review nits are addressed.
What changed
1. Pre-container failure feedback (5 paths in processor + 1 in orchestrator)
Linear-triggered tasks that fail before the agent container starts now produce a Linear comment + ❌ reaction within ~20s of label-apply. Previously, six distinct rejection points all silently dropped:
projectIdbgagent linear onboard-project"createTaskCorereturns non-201All six call sites use a shared helper
cdk/src/handlers/shared/linear-feedback.ts—reportIssueFailure(secretArn, issueId, message)posts the comment and reaction in parallel viaPromise.allSettled. Reuses the existing 5-minutegetLinearSecretcache.2. State transition at agent-start
Mirrors the existing
In Reviewtransition that fires at PR-open. The prompt addendum's step 1 now instructs the agent to transitionBacklog/Todo→In Progressbefore doing repo work, so Linear's state column reflects "being worked" during the minutes between label-apply and PR-open. Falls back toTodoifIn Progressdoesn't exist; skips if the issue is already inIn Progressor any later state. Doesn't loop onlist_issue_statuses.3. Stale-reaction sweep
Re-runs (label removed and re-applied; or pre-container ❌ followed by a successful retry) used to leave prior 👀/✅/❌ accumulated next to the new run's reaction. Added
_sweep_stale_reactions(issue_id)toagent/src/linear_reactions.py:exclude_id=rid).Smoke-tested on
backgroundagent-dev:react_task_startedtotal = 156ms; sweep done in background at +303ms; agent started at +3ms afterreact_task_startedexit.4. PR #63 review nits (4)
linear_reactions.py— track consecutive 401/403s; after 3 strikes, ERROR once and short-circuit until container restart. Replaces the prior behaviour where revoked tokens flooded CloudWatch with WARNs forever.linear_eyes_reaction_id: str | None = Noneinit inpipeline.pyinstead oflocals().get(...)in the crash handler.except Exceptioninresolve_linear_api_tokento(BotoCoreError, ClientError). Switchprint()→shell.log("WARN", ...).bgagent linear setupauto-link failures. Replaces misleading "runbgagent linear link <code>" suggestion (the command is non-functional in v1) with explicit DynamoDB put-item steps and a clear v3-OAuth note.Plumbing
linear-integration.ts— wiresLINEAR_API_TOKEN_SECRET_ARNinto the webhook processor + grants read onLinearIntegration.apiTokenSecret.agent.ts— same wiring fororchestrator.fn(declared afterLinearIntegrationso done in the stack rather than as a prop).LinearIntegrationWebhookProcessorFnandTaskOrchestratorOrchestratorFnboth carry the env var and IAM policy.Test plan
Unit tests — 1240 baseline → 1268 CDK, 526 → 532 agent, 196 CLI, all green:
cdk/test/handlers/shared/linear-feedback.test.ts(new, 13 tests) — mutation shape, auth header, error swallowing in 4 distinct failure modes,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).cdk/test/handlers/orchestrate-task-feedback.test.ts(new, 5 tests) — coversnotifyLinearOnConcurrencyCapwithwithDurableExecutionmocked.agent/tests/test_linear_reactions.py— extended with 5 sweep tests covering scoping rules (viewer-owned bgagent emojis only; preserves human reactions even with same emoji; preserves bot reactions of other emojis; sweep failure doesn't block 👀; viewer-id cached across calls).Lint + synth + agent quality: clean.
Smoke tested on backgroundagent-dev:
eyes-visible at +156msconfirmed via runtime container logsSmoke tests not run for paths 3 (defensive, no natural reproducer) and 5b (503, no natural reproducer) — covered by unit tests. Path 6 (concurrency cap) requires 4 simultaneous tasks to reproduce; covered by unit tests.
Reviewer notes
linear_*_msg_tsfields on TaskRecord — all per-issue state stays in Linear, accessed at runtime.TaskEventsTable2-reader-per-shard limit (closed by feat(fanout): migrate SlackNotifyFn to FanOutConsumer subscriber (#64) #79 anyway).