From a0edaa513f2884b97c4ce06be48793990232f4b1 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:15:27 +0200 Subject: [PATCH 1/7] feat(linear): reconcile agent activities --- CHANGELOG.md | 1 + .../src/agent-activity-reconciliation.test.ts | 102 ++++++++++++ .../src/agent-activity-reconciliation.ts | 145 ++++++++++++++++++ packages/linear/src/index.ts | 1 + packages/linear/src/types.ts | 1 + packages/linear/src/writeback.ts | 6 + 6 files changed, 256 insertions(+) create mode 100644 packages/linear/src/agent-activity-reconciliation.test.ts create mode 100644 packages/linear/src/agent-activity-reconciliation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e14779..9ae9d3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Added +- `@relayfile/adapter-linear` now owns cursor-safe AgentActivity reconciliation so consumers can prevent duplicate terminal activities after ambiguous provider writes. - `@relayfile/adapter-github/webhook-identity` now exports fail-closed check-run pull-request identity parsing for API and HTML URLs, including repository ownership validation for webhook consumers. - `@relayfile/adapter-gitlab` now creates issues, branches, and merge requests from file-native drafts; accepts or closes/reopens merge requests through canonical sidecars; and publishes schemas, examples, catalog paths, and `LAYOUT.md` guidance for every supported GitLab writeback route. - `@relayfile/adapter-github` now declares and routes pull-request `ready_for_review`, `labeled`, and `unlabeled` webhooks so review flows can wake on draft and policy-label transitions. diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts new file mode 100644 index 00000000..93e7bf7d --- /dev/null +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { reconcileLinearAgentActivity } from './agent-activity-reconciliation.js'; + +describe('Linear AgentActivity reconciliation', () => { + it('owns cursor pagination and finds exact frozen content', async () => { + const cursors: unknown[] = []; + const result = await reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + execute: async (request) => { + const variables = request.body.variables as { + sessionId: string; + after: string | null; + }; + cursors.push(variables.after); + assert.equal(request.action, 'list_agent_activities'); + assert.match(String(request.body.query), /RelayfileAgentSessionActivities/); + if (variables.after === null) { + return { + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: 'page-2' }, + }, + }, + }, + }, + }; + } + return { + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [{ + id: 'activity-1', + content: { + __typename: 'AgentActivityResponseContent', + body: 'Ready for review.', + }, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }; + }, + }); + + assert.deepEqual(cursors, [null, 'page-2']); + assert.deepEqual(result, { + found: true, + externalId: 'activity-1', + status: 200, + }); + }); + + it('fails closed on provider errors and repeated cursors', async () => { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'error', body: 'Failed.' }, + execute: async () => ({ + ok: true, + status: 200, + data: { errors: [{ message: 'permission denied' }] }, + }), + }), + /permission denied/, + ); + + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'error', body: 'Failed.' }, + execute: async () => ({ + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: 'same' }, + }, + }, + }, + }, + }), + }), + /invalid cursor/, + ); + }); +}); diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts new file mode 100644 index 00000000..d52b1fab --- /dev/null +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -0,0 +1,145 @@ +import type { + LinearAgentActivity, + LinearAgentActivityType, + LinearWritebackRequest, +} from './types.js'; + +export interface LinearAgentActivityProxyResponse { + readonly ok: boolean; + readonly status: number; + readonly data: unknown; +} + +export interface LinearAgentActivityReconciliationResult { + readonly found: boolean; + readonly externalId?: string; + readonly status: number; +} + +export type LinearAgentActivityRequestExecutor = ( + request: LinearWritebackRequest, +) => Promise; + +const AGENT_SESSION_ACTIVITIES_QUERY = ` + query RelayfileAgentSessionActivities($sessionId: String!, $after: String) { + agentSession(id: $sessionId) { + activities(first: 50, after: $after) { + nodes { + id + content { + __typename + ... on AgentActivityThoughtContent { body } + ... on AgentActivityElicitationContent { body } + ... on AgentActivityActionContent { action parameter result } + ... on AgentActivityResponseContent { body } + ... on AgentActivityErrorContent { body } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +`; + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function contentMatches(value: unknown, expected: LinearAgentActivity): boolean { + const content = record(value); + if (!content) return false; + const typeByTypename: Record = { + AgentActivityThoughtContent: 'thought', + AgentActivityElicitationContent: 'elicitation', + AgentActivityActionContent: 'action', + AgentActivityResponseContent: 'response', + AgentActivityErrorContent: 'error', + }; + const type = typeof content.__typename === 'string' + ? typeByTypename[content.__typename] + : undefined; + return type === expected.type && + optionalString(content.body) === expected.body && + optionalString(content.action) === expected.action && + optionalString(content.parameter) === expected.parameter && + optionalString(content.result) === expected.result; +} + +function graphqlError(data: unknown): string | undefined { + const envelope = record(data); + if (!Array.isArray(envelope?.errors) || envelope.errors.length === 0) { + return undefined; + } + return envelope.errors + .map((entry) => optionalString(record(entry)?.message)) + .filter((message): message is string => Boolean(message)) + .join('; ') || 'Linear GraphQL request failed'; +} + +function request(sessionId: string, after: string | null): LinearWritebackRequest { + return { + action: 'list_agent_activities', + method: 'POST', + endpoint: '/graphql', + body: { + query: AGENT_SESSION_ACTIVITIES_QUERY, + variables: { sessionId, after }, + }, + }; +} + +/** + * Reconcile an ambiguously completed AgentActivity create against Linear's + * frozen session history. The adapter owns the GraphQL contract, pagination, + * cursor validation, and provider response parsing; callers only supply their + * authenticated request executor. + */ +export async function reconcileLinearAgentActivity(input: { + readonly sessionId: string; + readonly activity: LinearAgentActivity; + readonly execute: LinearAgentActivityRequestExecutor; +}): Promise { + let after: string | null = null; + const seenCursors = new Set(); + for (;;) { + const response = await input.execute(request(input.sessionId, after)); + const providerError = graphqlError(response.data); + if (!response.ok || providerError) { + throw new Error( + providerError ?? + `Linear activity reconciliation failed with status ${response.status}`, + ); + } + const data = record(record(response.data)?.data); + const session = record(data?.agentSession); + const activities = record(session?.activities); + const nodes = Array.isArray(activities?.nodes) ? activities.nodes : []; + for (const nodeValue of nodes) { + const node = record(nodeValue); + if (node && contentMatches(node.content, input.activity)) { + const externalId = optionalString(node.id); + return { + found: true, + ...(externalId ? { externalId } : {}), + status: response.status, + }; + } + } + const pageInfo = record(activities?.pageInfo); + if (pageInfo?.hasNextPage !== true) { + return { found: false, status: response.status }; + } + const endCursor = optionalString(pageInfo.endCursor); + if (!endCursor || seenCursors.has(endCursor)) { + throw new Error('Linear activity reconciliation returned an invalid cursor'); + } + seenCursors.add(endCursor); + after = endCursor; + } +} diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts index 61bcc123..30560dfb 100644 --- a/packages/linear/src/index.ts +++ b/packages/linear/src/index.ts @@ -11,6 +11,7 @@ export * from './webhook-normalizer.js'; export * from './types.js'; export * from './queries.js'; export * from './writeback.js'; +export * from './agent-activity-reconciliation.js'; export * from './emit-auxiliary-files.js'; export * from './resources.js'; diff --git a/packages/linear/src/types.ts b/packages/linear/src/types.ts index e1def592..9c5d8018 100644 --- a/packages/linear/src/types.ts +++ b/packages/linear/src/types.ts @@ -49,6 +49,7 @@ export interface LinearWritebackRequest { | 'create-project' | 'delete_issue' | 'delete_label' + | 'list_agent_activities' | 'update_issue' | 'update_label' | 'update-project'; diff --git a/packages/linear/src/writeback.ts b/packages/linear/src/writeback.ts index 7f37bdc4..e65897b7 100644 --- a/packages/linear/src/writeback.ts +++ b/packages/linear/src/writeback.ts @@ -3,6 +3,12 @@ import { resources } from './resources.js'; import type { JsonValue, LinearAgentActivity, LinearAgentActivityType, LinearWritebackRequest } from './types.js'; export { ReadOnlyFieldError } from '@relayfile/adapter-core'; +export { reconcileLinearAgentActivity } from './agent-activity-reconciliation.js'; +export type { + LinearAgentActivityProxyResponse, + LinearAgentActivityReconciliationResult, + LinearAgentActivityRequestExecutor, +} from './agent-activity-reconciliation.js'; /** * Mounted Linear records used to resolve human-readable issue-create From f4694d0099bcb313bde0ebcb89a37e6f222e2714 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:20:15 +0200 Subject: [PATCH 2/7] fix(linear): validate reconciliation history --- .../src/agent-activity-reconciliation.test.ts | 54 +++++++++++++++++++ .../src/agent-activity-reconciliation.ts | 31 ++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts index 93e7bf7d..8a252011 100644 --- a/packages/linear/src/agent-activity-reconciliation.test.ts +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -99,4 +99,58 @@ describe('Linear AgentActivity reconciliation', () => { /invalid cursor/, ); }); + + it('fails closed when successful history responses are incomplete', async () => { + const malformedPayloads = [ + { data: { agentSession: null } }, + { data: { agentSession: {} } }, + { data: { agentSession: { activities: { pageInfo: { + hasNextPage: false, + } } } } }, + { data: { agentSession: { activities: { + nodes: [], + pageInfo: {}, + } } } }, + ]; + + for (const data of malformedPayloads) { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + execute: async () => ({ ok: true, status: 200, data }), + }), + /malformed history/, + ); + } + }); + + it('requires a stable provider id before confirming a content match', async () => { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + execute: async () => ({ + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [{ + content: { + __typename: 'AgentActivityResponseContent', + body: 'Ready for review.', + }, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }), + }), + /missing an id or content/, + ); + }); }); diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts index d52b1fab..84ce7b20 100644 --- a/packages/linear/src/agent-activity-reconciliation.ts +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -94,6 +94,10 @@ function request(sessionId: string, after: string | null): LinearWritebackReques }; } +function malformedHistory(message: string): never { + throw new Error(`Linear activity reconciliation returned malformed history: ${message}`); +} + /** * Reconcile an ambiguously completed AgentActivity create against Linear's * frozen session history. The adapter owns the GraphQL contract, pagination, @@ -116,23 +120,36 @@ export async function reconcileLinearAgentActivity(input: { `Linear activity reconciliation failed with status ${response.status}`, ); } - const data = record(record(response.data)?.data); + const envelope = record(response.data); + const data = record(envelope?.data); const session = record(data?.agentSession); const activities = record(session?.activities); - const nodes = Array.isArray(activities?.nodes) ? activities.nodes : []; + if (!envelope || !data || !session || !activities) { + malformedHistory('missing agent session activities'); + } + if (!Array.isArray(activities.nodes)) { + malformedHistory('activities.nodes is not an array'); + } + const pageInfo = record(activities.pageInfo); + if (!pageInfo || typeof pageInfo.hasNextPage !== 'boolean') { + malformedHistory('pageInfo.hasNextPage is not a boolean'); + } + const nodes = activities.nodes; for (const nodeValue of nodes) { const node = record(nodeValue); - if (node && contentMatches(node.content, input.activity)) { - const externalId = optionalString(node.id); + const externalId = optionalString(node?.id)?.trim(); + if (!node || !externalId || !record(node.content)) { + malformedHistory('activity node is missing an id or content'); + } + if (contentMatches(node.content, input.activity)) { return { found: true, - ...(externalId ? { externalId } : {}), + externalId, status: response.status, }; } } - const pageInfo = record(activities?.pageInfo); - if (pageInfo?.hasNextPage !== true) { + if (!pageInfo.hasNextPage) { return { found: false, status: response.status }; } const endCursor = optionalString(pageInfo.endCursor); From 3a226a7100a321f85d19b95cf91b31e2aebf9489 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:25:33 +0200 Subject: [PATCH 3/7] fix(linear): validate reconciliation content --- .../src/agent-activity-reconciliation.test.ts | 36 +++++++++++++++++++ .../src/agent-activity-reconciliation.ts | 33 +++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts index 8a252011..a8b966aa 100644 --- a/packages/linear/src/agent-activity-reconciliation.test.ts +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -153,4 +153,40 @@ describe('Linear AgentActivity reconciliation', () => { /missing an id or content/, ); }); + + it('rejects incomplete activity content before reporting absence', async () => { + const malformedContent = [ + {}, + { __typename: 'AgentActivityResponseContent' }, + { + __typename: 'AgentActivityActionContent', + action: 'deploy', + parameter: null, + }, + ]; + + for (const content of malformedContent) { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + execute: async () => ({ + ok: true, + status: 200, + data: { + data: { + agentSession: { + activities: { + nodes: [{ id: 'activity-1', content }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }), + }), + /malformed history/, + ); + } + }); }); diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts index 84ce7b20..aa711766 100644 --- a/packages/linear/src/agent-activity-reconciliation.ts +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -71,6 +71,34 @@ function contentMatches(value: unknown, expected: LinearAgentActivity): boolean optionalString(content.result) === expected.result; } +function validateActivityContent(value: unknown): Record { + const content = record(value); + const typename = optionalString(content?.__typename); + if (!content || !typename) { + malformedHistory('activity content is missing a typename'); + } + if ( + typename === 'AgentActivityThoughtContent' || + typename === 'AgentActivityElicitationContent' || + typename === 'AgentActivityResponseContent' || + typename === 'AgentActivityErrorContent' + ) { + if (typeof content.body !== 'string') { + malformedHistory(`${typename} is missing its body`); + } + } else if (typename === 'AgentActivityActionContent') { + for (const field of ['action', 'parameter', 'result'] as const) { + if ( + !Object.prototype.hasOwnProperty.call(content, field) || + (content[field] !== null && typeof content[field] !== 'string') + ) { + malformedHistory(`${typename} has an invalid ${field}`); + } + } + } + return content; +} + function graphqlError(data: unknown): string | undefined { const envelope = record(data); if (!Array.isArray(envelope?.errors) || envelope.errors.length === 0) { @@ -138,10 +166,11 @@ export async function reconcileLinearAgentActivity(input: { for (const nodeValue of nodes) { const node = record(nodeValue); const externalId = optionalString(node?.id)?.trim(); - if (!node || !externalId || !record(node.content)) { + if (!node || !externalId) { malformedHistory('activity node is missing an id or content'); } - if (contentMatches(node.content, input.activity)) { + const content = validateActivityContent(node.content); + if (contentMatches(content, input.activity)) { return { found: true, externalId, From 524469098807735aa4195bb66fcd476632d5d979 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:29:50 +0200 Subject: [PATCH 4/7] fix(linear): reject malformed error envelopes --- .../src/agent-activity-reconciliation.test.ts | 27 +++++++++++++++++++ .../src/agent-activity-reconciliation.ts | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts index a8b966aa..b0081d9a 100644 --- a/packages/linear/src/agent-activity-reconciliation.test.ts +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -125,6 +125,33 @@ describe('Linear AgentActivity reconciliation', () => { } }); + it('rejects malformed GraphQL error envelopes before trusting history', async () => { + for (const errors of [null, 'upstream failed', { message: 'failed' }]) { + await assert.rejects( + reconcileLinearAgentActivity({ + sessionId: 'session-1', + activity: { type: 'response', body: 'Ready for review.' }, + execute: async () => ({ + ok: true, + status: 200, + data: { + errors, + data: { + agentSession: { + activities: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }), + }), + /GraphQL errors is not an array/, + ); + } + }); + it('requires a stable provider id before confirming a content match', async () => { await assert.rejects( reconcileLinearAgentActivity({ diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts index aa711766..5fd16256 100644 --- a/packages/linear/src/agent-activity-reconciliation.ts +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -101,9 +101,13 @@ function validateActivityContent(value: unknown): Record { function graphqlError(data: unknown): string | undefined { const envelope = record(data); - if (!Array.isArray(envelope?.errors) || envelope.errors.length === 0) { + if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'errors')) { return undefined; } + if (!Array.isArray(envelope.errors)) { + malformedHistory('GraphQL errors is not an array'); + } + if (envelope.errors.length === 0) return undefined; return envelope.errors .map((entry) => optionalString(record(entry)?.message)) .filter((message): message is string => Boolean(message)) From 41c0e2aec40ac56a14abf23461e12a65b8fc0244 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:30:32 +0200 Subject: [PATCH 5/7] fix(linear): reject unknown activity variants --- packages/linear/src/agent-activity-reconciliation.test.ts | 1 + packages/linear/src/agent-activity-reconciliation.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts index b0081d9a..91f97388 100644 --- a/packages/linear/src/agent-activity-reconciliation.test.ts +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -184,6 +184,7 @@ describe('Linear AgentActivity reconciliation', () => { it('rejects incomplete activity content before reporting absence', async () => { const malformedContent = [ {}, + { __typename: 'AgentActivityFutureContent' }, { __typename: 'AgentActivityResponseContent' }, { __typename: 'AgentActivityActionContent', diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts index 5fd16256..01c59a5e 100644 --- a/packages/linear/src/agent-activity-reconciliation.ts +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -95,6 +95,8 @@ function validateActivityContent(value: unknown): Record { malformedHistory(`${typename} has an invalid ${field}`); } } + } else { + malformedHistory(`unsupported activity content typename ${typename}`); } return content; } From 1304556f5995278d14be07646fefeb2760545dcd Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:35:43 +0200 Subject: [PATCH 6/7] fix(linear): reconcile sessions with prompts --- packages/linear/src/agent-activity-reconciliation.test.ts | 8 +++++++- packages/linear/src/agent-activity-reconciliation.ts | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts index 91f97388..0292c13d 100644 --- a/packages/linear/src/agent-activity-reconciliation.test.ts +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -24,7 +24,13 @@ describe('Linear AgentActivity reconciliation', () => { data: { agentSession: { activities: { - nodes: [], + nodes: [{ + id: 'prompt-1', + content: { + __typename: 'AgentActivityPromptContent', + body: 'Please keep going.', + }, + }], pageInfo: { hasNextPage: true, endCursor: 'page-2' }, }, }, diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts index 01c59a5e..ec73e09a 100644 --- a/packages/linear/src/agent-activity-reconciliation.ts +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -33,6 +33,7 @@ const AGENT_SESSION_ACTIVITIES_QUERY = ` ... on AgentActivityActionContent { action parameter result } ... on AgentActivityResponseContent { body } ... on AgentActivityErrorContent { body } + ... on AgentActivityPromptContent { body } } } pageInfo { hasNextPage endCursor } @@ -81,7 +82,8 @@ function validateActivityContent(value: unknown): Record { typename === 'AgentActivityThoughtContent' || typename === 'AgentActivityElicitationContent' || typename === 'AgentActivityResponseContent' || - typename === 'AgentActivityErrorContent' + typename === 'AgentActivityErrorContent' || + typename === 'AgentActivityPromptContent' ) { if (typeof content.body !== 'string') { malformedHistory(`${typename} is missing its body`); From 7ffa783c661da56b4e6469c73fb3d412253af278 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Mon, 28 Sep 2026 12:42:21 +0200 Subject: [PATCH 7/7] fix(linear): bound activity reconciliation attempts --- .../src/agent-activity-reconciliation.test.ts | 40 +++++++++++++++---- .../src/agent-activity-reconciliation.ts | 17 ++++++-- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/linear/src/agent-activity-reconciliation.test.ts b/packages/linear/src/agent-activity-reconciliation.test.ts index 0292c13d..e3ed3bba 100644 --- a/packages/linear/src/agent-activity-reconciliation.test.ts +++ b/packages/linear/src/agent-activity-reconciliation.test.ts @@ -8,6 +8,7 @@ describe('Linear AgentActivity reconciliation', () => { const result = await reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async (request) => { const variables = request.body.variables as { sessionId: string; @@ -24,13 +25,24 @@ describe('Linear AgentActivity reconciliation', () => { data: { agentSession: { activities: { - nodes: [{ - id: 'prompt-1', - content: { - __typename: 'AgentActivityPromptContent', - body: 'Please keep going.', + nodes: [ + { + id: 'prompt-1', + createdAt: '2026-09-28T09:58:00.000Z', + content: { + __typename: 'AgentActivityPromptContent', + body: 'Please keep going.', + }, }, - }], + { + id: 'old-identical-activity', + createdAt: '2026-09-28T09:59:00.000Z', + content: { + __typename: 'AgentActivityResponseContent', + body: 'Ready for review.', + }, + }, + ], pageInfo: { hasNextPage: true, endCursor: 'page-2' }, }, }, @@ -47,6 +59,7 @@ describe('Linear AgentActivity reconciliation', () => { activities: { nodes: [{ id: 'activity-1', + createdAt: '2026-09-28T10:00:01.000Z', content: { __typename: 'AgentActivityResponseContent', body: 'Ready for review.', @@ -74,6 +87,7 @@ describe('Linear AgentActivity reconciliation', () => { reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'error', body: 'Failed.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async () => ({ ok: true, status: 200, @@ -87,6 +101,7 @@ describe('Linear AgentActivity reconciliation', () => { reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'error', body: 'Failed.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async () => ({ ok: true, status: 200, @@ -124,6 +139,7 @@ describe('Linear AgentActivity reconciliation', () => { reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async () => ({ ok: true, status: 200, data }), }), /malformed history/, @@ -137,6 +153,7 @@ describe('Linear AgentActivity reconciliation', () => { reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async () => ({ ok: true, status: 200, @@ -163,6 +180,7 @@ describe('Linear AgentActivity reconciliation', () => { reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async () => ({ ok: true, status: 200, @@ -171,6 +189,7 @@ describe('Linear AgentActivity reconciliation', () => { agentSession: { activities: { nodes: [{ + createdAt: '2026-09-28T10:00:01.000Z', content: { __typename: 'AgentActivityResponseContent', body: 'Ready for review.', @@ -183,7 +202,7 @@ describe('Linear AgentActivity reconciliation', () => { }, }), }), - /missing an id or content/, + /missing an id, createdAt, or content/, ); }); @@ -204,6 +223,7 @@ describe('Linear AgentActivity reconciliation', () => { reconcileLinearAgentActivity({ sessionId: 'session-1', activity: { type: 'response', body: 'Ready for review.' }, + createdAtOrAfter: '2026-09-28T10:00:00.000Z', execute: async () => ({ ok: true, status: 200, @@ -211,7 +231,11 @@ describe('Linear AgentActivity reconciliation', () => { data: { agentSession: { activities: { - nodes: [{ id: 'activity-1', content }], + nodes: [{ + id: 'activity-1', + createdAt: '2026-09-28T10:00:01.000Z', + content, + }], pageInfo: { hasNextPage: false, endCursor: null }, }, }, diff --git a/packages/linear/src/agent-activity-reconciliation.ts b/packages/linear/src/agent-activity-reconciliation.ts index ec73e09a..d53946d1 100644 --- a/packages/linear/src/agent-activity-reconciliation.ts +++ b/packages/linear/src/agent-activity-reconciliation.ts @@ -26,6 +26,7 @@ const AGENT_SESSION_ACTIVITIES_QUERY = ` activities(first: 50, after: $after) { nodes { id + createdAt content { __typename ... on AgentActivityThoughtContent { body } @@ -143,8 +144,13 @@ function malformedHistory(message: string): never { export async function reconcileLinearAgentActivity(input: { readonly sessionId: string; readonly activity: LinearAgentActivity; + readonly createdAtOrAfter: string; readonly execute: LinearAgentActivityRequestExecutor; }): Promise { + const createdAtOrAfter = Date.parse(input.createdAtOrAfter); + if (!Number.isFinite(createdAtOrAfter)) { + throw new Error('Linear activity reconciliation requires a valid creation-time boundary'); + } let after: string | null = null; const seenCursors = new Set(); for (;;) { @@ -174,11 +180,16 @@ export async function reconcileLinearAgentActivity(input: { for (const nodeValue of nodes) { const node = record(nodeValue); const externalId = optionalString(node?.id)?.trim(); - if (!node || !externalId) { - malformedHistory('activity node is missing an id or content'); + const createdAtValue = optionalString(node?.createdAt); + const createdAt = createdAtValue ? Date.parse(createdAtValue) : Number.NaN; + if (!node || !externalId || !Number.isFinite(createdAt)) { + malformedHistory('activity node is missing an id, createdAt, or content'); } const content = validateActivityContent(node.content); - if (contentMatches(content, input.activity)) { + if ( + createdAt >= createdAtOrAfter && + contentMatches(content, input.activity) + ) { return { found: true, externalId,