Skip to content

fix(edge-worker): post agent activities in one Linear request instead of three - #1448

Open
michaelkd01 wants to merge 3 commits into
cyrusagents:mainfrom
michaelkd01:soc-403-agent-activity-id
Open

michaelkd01 wants to merge 3 commits into
cyrusagents:mainfrom
michaelkd01:soc-403-agent-activity-id

Conversation

@michaelkd01

Copy link
Copy Markdown

Summary

Every agent activity Cyrus posts to Linear costs three API requests instead of one. Two of the three are read-back queries for an activity that was just created, and only its id is ever used — an id the mutation response already carries.

This PR removes both extra requests. No behaviour change is intended: it is a request-count fix.

Mechanism

AgentActivityPayload.agentActivity in @linear/sdk is an unmemoized getter. From the generated SDK (v64, the version this repo resolves):

/** The agent activity that was created or updated. */
get agentActivity(): LinearFetch<AgentActivity> | undefined {
  return new AgentActivityQuery(this._request).fetch(this._agentActivity.id);
}

/** The ID of agent activity that was created or updated. */
get agentActivityId(): string | undefined {
  return this._agentActivity?.id;
}

Each access of agentActivity constructs a fresh AgentActivityQuery and issues another round trip. It caches nothing. agentActivityId reads an id already present in the mutation response, at zero request cost.

Both activity-posting paths touched the getter twice per post — once for truthiness in the if, once for the awaited assignment — and consumed only .id:

// packages/edge-worker/src/sinks/LinearActivitySink.ts
if (result.success && result.agentActivity) {   // access 1 -> query, discarded
  const agentActivity = await result.agentActivity; // access 2 -> query, awaited
  return { activityId: agentActivity.id };          // only the id is used
}
// packages/edge-worker/src/ActivityPoster.ts
if (result.agentActivity) {                    // access 1 -> query, discarded
  const activity = await result.agentActivity; // access 2 -> query, awaited
  ...
  return activity.id;                          // only the id is used
}

So: 1 mutation + 2 read-back queries = 3 requests per activity.

The promise from the first access is never awaited, so when a read-back fails its rejection is unhandled. ActivityPoster's try/catch does not cover this — a catch block cannot handle the rejection of a promise it never awaited. Under Node's default --unhandled-rejections=throw this is a process-level failure, and it is most likely to fire exactly when the budget is already exhausted.

The signature that identified it

In a single 17-minute burst we observed 793 awaited read-back queries against 796 unawaited ones. The near-equality is the tell: a read-back genuinely needed once per post would not be almost exactly doubled. Of the 796 unawaited queries, 761 landed within one second of their awaited twin, median separation 0.021s — two accesses of the same getter in the same synchronous stretch of one postActivity call, not two independent operations that happened to coincide.

Operator-side impact

Tripling every write divides the usable write budget by three. Against Linear's 5,000 requests/hour, an integration that should sustain 5,000 activities/hour is capped at an effective ~1,667 activities/hour.

On 2026-08-31 this produced 4,161 rate-limit occurrences in 17 minutes. The pattern has recurred episodically since 2026-05-15, surfacing whenever activity volume rises — which is to say, whenever agents are busiest and their timeline updates matter most. Once the budget is spent, the read-backs are also the first requests to fail, which is what turns a throughput problem into the unhandled rejection described above.

The fix

Read the id from result.agentActivityId and keep the getter out of the condition entirely, so it is never evaluated:

const activityId = result.agentActivityId;

if (result.success && activityId) {
  return { activityId };
}

return {};

Failure semantics are preserved. A mutation that did not succeed takes exactly the same branch it does today, returns exactly the same value, and issues exactly the same one request; agentActivityId is a plain optional-chained read that cannot throw and cannot fetch. Tests covering both failure branches are included and pass unchanged before and after.

Measurement: 3 → 1, and how it was counted

The count is taken at the transport, not at the caller. A test double reproduces AgentActivityPayload with agentActivity as a real getter that appends one entry per access, and agentActivityId as a plain accessor that appends nothing; the createAgentActivity mutation appends one entry when invoked. The request count for a call is then log.operations.length. This matters because the existing mocks in LinearActivitySink.test.ts model agentActivity as a plain already-resolved promise property — touching a property twice is free, so those mocks cannot observe this defect at all. Reproducing the getter is what makes the cost visible.

Before, one postActivity call:

AssertionError: expected [ Array(3) ] to deeply equal [ 'mutation:agentActivityCreate' ]

  [
    "mutation:agentActivityCreate",
+   "query:agentActivity",
+   "query:agentActivity",
  ]

and a burst of three posts logged 9 operations, with Error: Ratelimit exceeded arriving at the unhandledRejection handler.

After: 1 operation per post, 3 across a burst of three, and nothing unhandled. Both posting paths are covered.

Changes

  • packages/edge-worker/src/sinks/LinearActivitySink.ts — postActivity uses agentActivityId; getter removed from the condition.
  • packages/edge-worker/src/ActivityPoster.ts — same fix in postActivityDirect, the second posting path, found by searching for the construct rather than the file. Happy to split this out if you would rather review it separately.
  • packages/edge-worker/test/agent-activity-payload-double.ts — the payload double and request counter, shared by both suites (following the existing test/*-utils.ts helper convention).
  • packages/edge-worker/test/LinearActivitySink.request-count.test.ts, packages/edge-worker/test/ActivityPoster.request-count.test.ts — new.
  • packages/edge-worker/test/LinearActivitySink.test.ts — existing mocks now also supply agentActivityId alongside agentActivity, so they match the shape of the payload they stand in for. Their assertions are unchanged.
  • CHANGELOG.md — entry under ## [Unreleased] / ### Fixed.

Verification

pnpm build          # clean
pnpm typecheck      # clean
pnpm lint           # exit 0, 11 pre-existing warnings, none in changed files
pnpm test:packages:run
  packages/edge-worker  Test Files 72 passed (72)   Tests 791 passed | 1 skipped (792)
  all packages          exit 0

Tracked internally as SOC-403.

Michael Davidson added 2 commits September 1, 2026 09:21
…SDK getter

`AgentActivityPayload.agentActivity` is a getter with no memoisation: each
access constructs a fresh `AgentActivityQuery` and issues another round trip.
Both activity-posting paths touched it twice per post -- once for truthiness in
the `if`, once for the awaited assignment -- while consuming only `.id`. Every
agent activity therefore cost three Linear requests instead of one.

The promise produced by the `if`-condition access is never awaited, so a failed
read-back also leaves an unhandled rejection. `ActivityPoster`'s try/catch does
not cover it: the catch block never awaits that promise.

`agentActivityId` returns the same id from the mutation response the SDK is
already holding, at no request cost. Failure semantics are unchanged: a
mutation that did not succeed takes exactly the same branch as before.

Adds a request-counting harness that reproduces the SDK getter, because the
existing mocks model `agentActivity` as a plain resolved promise property and
cannot observe the cost of touching it twice.
@michaelkd01

Copy link
Copy Markdown
Author

Self-reported finding on this PR, before it merges.

Switching both posting paths from await result.agentActivity to result.agentActivityId is correct for the Linear SDK, but it silently drops the id on the CLI issue-tracker adapter, which returns a payload that carries no agentActivityId at all.

CLIIssueTrackerService.createAgentActivity (packages/core/src/issue-tracker/adapters/CLIIssueTrackerService.ts) ends with:

// Return success payload with agentActivity that can be awaited
// AgentSessionManager expects result.agentActivity to be promise-like
return {
	agentActivity: Promise.resolve({ id: activityId }),
	success: true,
	lastSyncId: Date.now(),
} as AgentActivityPayload;

agentActivity there is a plain, already-resolved promise held as an ordinary property — not the SDK's lazy getter — and agentActivityId is absent. So on that adapter result.agentActivityId is undefined, and as this PR stands ActivityPoster.postActivityDirect falls through to return null and LinearActivitySink.postActivity returns {}, where both previously returned the id. The as AgentActivityPayload cast is why the compiler does not catch it.

The fix I'd propose is to prefer the free field and fall back to the property, in both paths:

const activityId = result.agentActivityId ?? (await result.agentActivity)?.id

On the Linear SDK path this costs nothing: agentActivityId is present on every successful mutation response, ?? short-circuits before the right-hand side is evaluated, and the getter is therefore never touched — the one-request-per-activity property this PR exists to establish is preserved. On the CLI adapter the right-hand side awaits an already-resolved promise, which is not a request. The getter stays out of the if condition for the same reason.

A commit implementing this, plus a test double shaped like the CLI adapter's payload alongside the existing SDK-shaped double (so the request-count guarantee and this path are both covered), is following shortly on this branch.

Reading `agentActivityId` alone is correct for the Linear SDK payload but not
for `CLIIssueTrackerService.createAgentActivity`, which returns

    {
      agentActivity: Promise.resolve({ id: activityId }),
      success: true,
      lastSyncId: Date.now(),
    } as AgentActivityPayload

-- the id is carried only on `agentActivity`, as a plain already-resolved
promise property rather than the SDK's lazy getter, and `agentActivityId` is
absent. On that adapter `postActivityDirect` returned null and
`LinearActivitySink.postActivity` returned {}, where both previously returned
the id. The `as` cast is why the compiler did not catch it.

Both paths now read `result.agentActivityId ?? (await result.agentActivity)?.id`.
The fallback costs nothing on the Linear path: `agentActivityId` is present on
every successful mutation response, `??` short-circuits before the right-hand
side is evaluated, and the getter is never touched -- so the one-request-per-
activity property holds. On the CLI adapter the fallback awaits a promise that
is already settled, which is not a round trip. The getter stays out of the `if`
condition so a rejected read-back is never orphaned, and in LinearActivitySink
it is reached only once `success` holds, keeping failed mutations free.

Adds a CLI-adapter-shaped payload double alongside the SDK-shaped one, and a
guard that pins the id to a falsy-but-present value -- the only case that tells
`??` from `||`, since both short-circuit on a populated id. Failure branches are
unchanged.
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