From e69baa34991ccd0377619b752a63daa8ba04dfc1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:15:07 -0700 Subject: [PATCH] fix(linear): stop writes when their caller lifetime ends (#35020) * fix(linear): bound write preparation and mutation to one caller lifetime * fix(linear): isolate cancellable preparation from shared reads * test(linear): keep CLI lifetime coverage in its compiler project * fix(linear): preserve comment change failure guidance * test(linear): place CLI and runtime coverage with integration tests * test(linear): reset CLI exit status across runtimes * fix(linear): preserve truthful confirmation deadline causes * test(linear): support renamed workspace selector in fixture * test(linear): adapt relation lifetime fixture to client factory --- src/main/linear/issue-context/client.ts | 53 +- src/main/linear/issue-context/fanout.ts | 6 +- .../linear/issue-context/read-context.test.ts | 2 +- src/main/linear/issue-context/read-context.ts | 16 +- .../linear/issue-relation-lifetime.test.ts | 118 +++++ src/main/linear/issue-relation-write.ts | 13 +- src/main/linear/linear-issue-comments.ts | 143 +++--- src/main/linear/linear-issue-lookups.ts | 55 +- src/main/linear/linear-issue-mutations.ts | 156 +++--- .../linear-project-cancellation.test.ts | 134 +++++ .../linear/linear-project-collection-read.ts | 11 +- src/main/linear/linear-project-models.ts | 11 +- src/main/linear/linear-project-queries.ts | 28 +- .../linear/linear-project-team-queries.ts | 11 +- src/main/linear/linear-team-pages.ts | 35 +- src/main/linear/teams.test.ts | 2 +- src/main/linear/teams.ts | 46 +- src/main/runtime/linear-save-issue.test.ts | 16 +- .../rpc/methods/linear-agent-access.test.ts | 203 +++++--- .../rpc/methods/linear-agent-access.ts | 28 +- .../linear-agent-project-access.spec.ts | 10 +- .../runtime-linear-command-dependencies.ts | 4 + .../runtime-linear-comment-commands.ts | 301 +++++------ .../runtime-linear-comment-edit.test.ts | 10 +- .../runtime-linear-comment-lookup-commands.ts | 85 ++-- .../runtime/runtime-linear-create-commands.ts | 210 ++++---- .../runtime/runtime-linear-dedupe-commands.ts | 39 +- .../runtime-linear-label-write-commands.ts | 18 +- .../runtime-linear-project-write-commands.ts | 84 +++- .../runtime/runtime-linear-retry-commands.ts | 11 +- .../runtime/runtime-linear-save-commands.ts | 213 ++++---- .../runtime-linear-save-fields-commands.ts | 18 +- .../runtime/runtime-linear-state-commands.ts | 220 ++++---- .../runtime-linear-task-fields-commands.ts | 87 ++-- .../runtime-linear-team-write-commands.ts | 18 +- ...tion.ts => runtime-linear-write-action.ts} | 33 +- .../runtime-linear-write-lifetime.test.ts | 472 ++++++++++++++++++ ...me-rpc-local-disconnect-forwarding.test.ts | 119 +++-- .../runtime-rpc-request-admission.ts | 4 +- tests/e2e/linear-write-lifetime.unit.test.ts | 448 +++++++++++++++++ 40 files changed, 2532 insertions(+), 959 deletions(-) create mode 100644 src/main/linear/issue-relation-lifetime.test.ts create mode 100644 src/main/linear/linear-project-cancellation.test.ts rename src/main/runtime/{runtime-linear-comment-action.ts => runtime-linear-write-action.ts} (74%) create mode 100644 src/main/runtime/runtime-linear-write-lifetime.test.ts create mode 100644 tests/e2e/linear-write-lifetime.unit.test.ts diff --git a/src/main/linear/issue-context/client.ts b/src/main/linear/issue-context/client.ts index 7b4442242bd..1097d6c1e3c 100644 --- a/src/main/linear/issue-context/client.ts +++ b/src/main/linear/issue-context/client.ts @@ -75,12 +75,15 @@ export async function searchLinearIssuesForAgents(args: { export async function resolveIssue( identifier: string, - selectors: { workspaceId?: string | null; organizationUrlKey?: string | null } + selectors: { workspaceId?: string | null; organizationUrlKey?: string | null }, + signal?: AbortSignal ): Promise { const workspace = resolveWorkspaceSelector(selectors, getConnectedWorkspaces()) const selection = workspace?.id ?? selectors.workspaceId ?? 'all' const { entries, failures: entryFailures } = - selection === 'all' ? getFanoutClientEntries() : getExplicitClientEntries(selection) + selection === 'all' + ? getFanoutClientEntries(signal) + : getExplicitClientEntries(selection, signal) if (entries.length === 0) { throwIfExplicitWorkspaceHasConnectedAlternatives(selection) if (entryFailures[0]) { @@ -91,7 +94,7 @@ export async function resolveIssue( }) } - const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures) + const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures, signal) if (results.length === 0) { throw linearError('linear_issue_not_found', `Linear issue ${identifier} was not found.`) @@ -118,12 +121,15 @@ export function getRequiredEntry( return entry } -function getExplicitClientEntries(workspaceId?: string): { +function getExplicitClientEntries( + workspaceId?: string, + signal?: AbortSignal +): { entries: LinearClientForWorkspace[] failures: WorkspaceReadFailure[] } { try { - return { entries: getClients(workspaceId), failures: [] } + return { entries: getClients(workspaceId, signal), failures: [] } } catch (error) { if (error instanceof LinearAgentAccessError) { throw error @@ -161,11 +167,13 @@ function throwIfExplicitWorkspaceHasConnectedAlternatives( export async function withLinearRead( entry: LinearClientForWorkspace, read: () => Promise, - selection?: (string & {}) | 'all' + selection?: (string & {}) | 'all', + signal?: AbortSignal ): Promise { void selection - await acquire() + await acquire(signal) try { + signal?.throwIfAborted() return await read() } catch (error) { if (isAuthError(error)) { @@ -182,16 +190,22 @@ export async function withLinearRead( async function readIssueWorkspace( entry: LinearClientForWorkspace, - identifier: string + identifier: string, + signal?: AbortSignal ): Promise { - const response = await withLinearRead(entry, async () => { - const client = getPublicFileUrlClient(entry) - const raw = await client.client.rawRequest>( - ISSUE_QUERY, - { id: identifier } - ) - return raw.data?.issue ?? null - }) + const response = await withLinearRead( + entry, + async () => { + const client = getPublicFileUrlClient(entry, signal) + const raw = await client.client.rawRequest>( + ISSUE_QUERY, + { id: identifier } + ) + return raw.data?.issue ?? null + }, + undefined, + signal + ) return response ? { issue: mapIssue(response), workspace: entry.workspace } : null } @@ -199,15 +213,16 @@ async function readIssueWorkspaces( entries: LinearClientForWorkspace[], identifier: string, selection: (string & {}) | 'all', - initialFailures: WorkspaceReadFailure[] = [] + initialFailures: WorkspaceReadFailure[] = [], + signal?: AbortSignal ): Promise { if (selection !== 'all') { - const selected = await readIssueWorkspace(entries[0], identifier) + const selected = await readIssueWorkspace(entries[0], identifier, signal) return selected ? [selected] : [] } const settled = await Promise.allSettled( - entries.map((entry) => readIssueWorkspace(entry, identifier)) + entries.map((entry) => readIssueWorkspace(entry, identifier, signal)) ) const results: ResolvedIssue[] = [] const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error) diff --git a/src/main/linear/issue-context/fanout.ts b/src/main/linear/issue-context/fanout.ts index 9bc543d6e0a..e2f62be9a42 100644 --- a/src/main/linear/issue-context/fanout.ts +++ b/src/main/linear/issue-context/fanout.ts @@ -11,20 +11,20 @@ export type WorkspaceReadFailure = { error: LinearAgentAccessError } -export function getFanoutClientEntries(): { +export function getFanoutClientEntries(signal?: AbortSignal): { entries: LinearClientForWorkspace[] failures: WorkspaceReadFailure[] } { const workspaces = getStatus().workspaces ?? [] if (workspaces.length === 0) { - return { entries: getClients('all'), failures: [] } + return { entries: getClients('all', signal), failures: [] } } const entries: LinearClientForWorkspace[] = [] const failures: WorkspaceReadFailure[] = [] for (const workspace of workspaces) { try { - const entry = getClients(workspace.id)[0] + const entry = getClients(workspace.id, signal)[0] if (entry) { entries.push(entry) } diff --git a/src/main/linear/issue-context/read-context.test.ts b/src/main/linear/issue-context/read-context.test.ts index 7a03b56349c..6c1ee76bf13 100644 --- a/src/main/linear/issue-context/read-context.test.ts +++ b/src/main/linear/issue-context/read-context.test.ts @@ -102,7 +102,7 @@ describe('Linear issue context', () => { } } }) - expect(getClients).toHaveBeenCalledWith('workspace-stably') + expect(getClients).toHaveBeenCalledWith('workspace-stably', undefined) }) it('returns partial context and attachment metadata when the optional comment read fails', async () => { diff --git a/src/main/linear/issue-context/read-context.ts b/src/main/linear/issue-context/read-context.ts index a30112bbae3..5a0f4bf7a2c 100644 --- a/src/main/linear/issue-context/read-context.ts +++ b/src/main/linear/issue-context/read-context.ts @@ -15,8 +15,10 @@ import { readOptionalIncludes } from './includes' export async function readLinearIssueContext( request: LinearIssueRequest, - resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise + resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() if (request.workspaceId === 'all') { throw linearError('linear_invalid_workspace', '--workspace all is not valid for issue reads.', { nextSteps: ['Pass a concrete Linear workspace id or omit --workspace.'] @@ -40,10 +42,14 @@ export async function readLinearIssueContext( throw linearError('linear_issue_required', 'Pass an issue id or use --current.') } - const resolved = await resolveIssue(identifier, { - workspaceId: request.workspaceId ?? currentLink?.workspaceId ?? undefined, - organizationUrlKey: parsed?.organizationUrlKey ?? currentLink?.organizationUrlKey - }) + const resolved = await resolveIssue( + identifier, + { + workspaceId: request.workspaceId ?? currentLink?.workspaceId ?? undefined, + organizationUrlKey: parsed?.organizationUrlKey ?? currentLink?.organizationUrlKey + }, + signal + ) return buildIssueContextResult(resolved, request, currentLink) } diff --git a/src/main/linear/issue-relation-lifetime.test.ts b/src/main/linear/issue-relation-lifetime.test.ts new file mode 100644 index 00000000000..083326f62dd --- /dev/null +++ b/src/main/linear/issue-relation-lifetime.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { acquire, release } from './linear-request-concurrency' +import { linearError } from './issue-context/errors' +import { runLinearWriteAction } from '../runtime/runtime-linear-write-action' +import { writeIssueRelation } from './issue-relation-write' + +const mocks = vi.hoisted(() => ({ rawRequest: vi.fn() })) +vi.mock('./client', () => ({ + getClients: () => [{ workspace: { id: 'w1' }, apiKey: 'inert-fixture' }], + isAuthError: () => false +})) +vi.mock('./linear-sdk', () => ({ + createLinearClient: () => ({ client: { rawRequest: mocks.rawRequest } }) +})) + +const issue = { id: 'issue-1', identifier: 'ENG-1', title: 'Title', url: 'https://example.com/1' } +const relatedIssue = { ...issue, id: 'issue-2', identifier: 'ENG-2' } +const unconfirmed = () => + linearError('linear_write_unconfirmed', 'Read the relations before retrying.') + +beforeEach(() => { + vi.useFakeTimers() + mocks.rawRequest.mockReset() +}) +afterEach(() => vi.useRealTimers()) + +it.each(['add', 'remove'] as const)( + 'does not %s after a held relation scan loses its caller or deadline', + async (operation) => { + for (const reason of ['caller', 'deadline']) { + const caller = new AbortController() + let finish = () => {} + mocks.rawRequest.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => + resolve({ + data: { + issue: { + relations: { + nodes: + operation === 'remove' ? [{ id: 'r1', type: 'blocks', relatedIssue }] : [], + pageInfo: { hasNextPage: false } + } + } + } + }) + }) + ) + let outcome: unknown + const pending = runLinearWriteAction( + async () => null, + (_owner, options) => + writeIssueRelation({ + issue, + relatedIssue, + relationship: 'blocks', + operation, + workspaceId: 'w1', + ...options + }), + unconfirmed, + () => linearError('linear_write_failed', 'Failed'), + caller.signal + ).catch((error) => { + outcome = error + }) + try { + await vi.advanceTimersByTimeAsync(0) + expect(mocks.rawRequest).toHaveBeenCalledTimes(1) + if (reason === 'caller') { + caller.abort() + } else { + await vi.advanceTimersByTimeAsync(25_000) + } + await pending + expect(outcome).toMatchObject({ code: 'linear_timeout' }) + finish() + await vi.advanceTimersByTimeAsync(0) + expect(mocks.rawRequest).toHaveBeenCalledTimes(1) + mocks.rawRequest.mockClear() + await Promise.all([acquire(), acquire(), acquire(), acquire()]) + for (let i = 0; i < 4; i++) { + release() + } + } finally { + finish() + await pending + } + } + } +) + +it('removes cancelled relation queue waits without leaking a permit or invoking Linear', async () => { + await Promise.all([acquire(), acquire(), acquire(), acquire()]) + const caller = new AbortController() + const pending = writeIssueRelation({ + issue, + relatedIssue, + relationship: 'blocks', + operation: 'add', + workspaceId: 'w1', + signal: caller.signal + }) + caller.abort(new Error('disconnected')) + try { + await expect(pending).rejects.toThrow('disconnected') + } finally { + for (let i = 0; i < 4; i++) { + release() + } + } + expect(mocks.rawRequest).not.toHaveBeenCalled() + await Promise.all([acquire(), acquire(), acquire(), acquire()]) + for (let i = 0; i < 4; i++) { + release() + } +}) diff --git a/src/main/linear/issue-relation-write.ts b/src/main/linear/issue-relation-write.ts index 09c228bf9d5..9b340d28bd1 100644 --- a/src/main/linear/issue-relation-write.ts +++ b/src/main/linear/issue-relation-write.ts @@ -29,17 +29,20 @@ export async function writeIssueRelation(params: { operation: 'add' | 'remove' workspaceId: string signal?: AbortSignal + beforeInvoke?: () => void }): Promise { const entry = getClients(params.workspaceId)[0] if (!entry) { throw new LinearWriteFailure('failed', 'Not connected to Linear') } - await acquire() + await acquire(params.signal) try { + params.signal?.throwIfAborted() const client = params.signal ? createLinearClient({ apiKey: entry.apiKey, signal: params.signal }) : entry.client const existing = await findExistingRelation(client, params) + params.signal?.throwIfAborted() if (params.operation === 'add' && existing) { return result(params, existing, true) } @@ -47,9 +50,13 @@ export async function writeIssueRelation(params: { return result(params, absentRelation(params), true) } if (params.operation === 'remove' && existing) { + params.beforeInvoke?.() + params.signal?.throwIfAborted() await deleteLinearIssueRelation(client, existing.id) return result(params, existing, false) } + params.beforeInvoke?.() + params.signal?.throwIfAborted() const created = await createLinearIssueRelation(client, relationCreateInput(params)) return result(params, normalizeRelation(created, params.issue.id), false) } catch (error) { @@ -68,9 +75,11 @@ async function findExistingRelation( issue: { id: string; identifier: string } relatedIssue: { id: string } relationship: LinearIssueRelationship + signal?: AbortSignal } ): Promise { for (const direction of relationDirections(params.relationship)) { + params.signal?.throwIfAborted() const scan = await findRelationInDirection(client, params, direction) if (scan.relation) { return scan.relation @@ -97,6 +106,7 @@ async function findRelationInDirection( issue: { id: string } relatedIssue: { id: string } relationship: LinearIssueRelationship + signal?: AbortSignal }, direction: RelationDirection ): Promise<{ relation: LinearIssueRelationWriteResult['relation'] | null; hasMore: boolean }> { @@ -104,6 +114,7 @@ async function findRelationInDirection( let inspected = 0 while (inspected < RELATION_WRITE_READ_CAP) { + params.signal?.throwIfAborted() const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, RELATION_WRITE_READ_CAP - inspected) const raw = await client.client.rawRequest>( direction === 'outbound' ? RELATIONS_QUERY : INVERSE_RELATIONS_QUERY, diff --git a/src/main/linear/linear-issue-comments.ts b/src/main/linear/linear-issue-comments.ts index 2b7e727bbf3..1790454e380 100644 --- a/src/main/linear/linear-issue-comments.ts +++ b/src/main/linear/linear-issue-comments.ts @@ -71,38 +71,51 @@ export async function addIssueCommentForAgent( issueId: string, body: string, workspaceId: string, - options: { id: string; parentId?: string | null; signal?: AbortSignal } + options: { id: string; parentId?: string | null; signal?: AbortSignal; beforeInvoke?: () => void } ): Promise { const entry = getClients(workspaceId)[0] if (!entry) { throw new LinearWriteFailure('failed', 'Not connected to Linear') } - return runLinearWrite(entry, options.signal, async (client) => { - const result = await client.createComment({ - id: options.id, - issueId, - body, - ...(options.parentId ? { parentId: options.parentId } : {}) - }) - if (!result.success) { - throw new LinearWriteFailure('failed', 'Failed to create comment') - } - const comment = await confirmLinearWrite( - 'Comment was created but could not be retrieved', - async () => result.comment - ) - if (!comment?.id) { - throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved') - } - const record = await confirmLinearWrite('Comment was created but could not be retrieved', () => - readCommentWriteRecord(client, comment.id) - ) - if (!record) { - throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved') - } - return record - }) + return runLinearWrite( + entry, + options.signal, + async (client) => { + const result = await client.createComment({ + id: options.id, + issueId, + body, + ...(options.parentId ? { parentId: options.parentId } : {}) + }) + if (!result.success) { + throw new LinearWriteFailure('failed', 'Failed to create comment') + } + options.signal?.throwIfAborted() + const comment = await confirmLinearWrite( + 'Comment was created but could not be retrieved', + async () => result.comment + ) + if (!comment?.id) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Comment was created but could not be retrieved' + ) + } + const record = await confirmLinearWrite( + 'Comment was created but could not be retrieved', + () => readCommentWriteRecord(client, comment.id, options.signal) + ) + if (!record) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Comment was created but could not be retrieved' + ) + } + return record + }, + options.beforeInvoke + ) } export async function updateIssueCommentForAgent( @@ -125,7 +138,7 @@ export async function updateIssueCommentForAgent( throw new LinearWriteFailure('failed', 'Failed to edit comment') } const record = await confirmLinearWrite('Comment was edited but could not be retrieved', () => - readCommentWriteRecord(client, commentId) + readCommentWriteRecord(client, commentId, options.signal) ) if (!record) { throw new LinearWriteFailure('unconfirmed', 'Comment was edited but could not be retrieved') @@ -163,51 +176,59 @@ export async function createIssueAttachment( issueId: string, input: { id: string; title: string; url: string }, workspaceId: string, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; beforeInvoke?: () => void } = {} ): Promise { const entry = getClients(workspaceId)[0] if (!entry) { throw new LinearWriteFailure('failed', 'Not connected to Linear') } - return runLinearWrite(entry, options.signal, async (client) => { - const result = await client.createAttachment({ - id: input.id, - issueId, - title: input.title, - url: input.url - }) - if (!result.success) { - throw new LinearWriteFailure('failed', 'Failed to create attachment') - } - const attachment = await confirmLinearWrite( - 'Attachment was created but could not be retrieved', - async () => result.attachment - ) - if (!attachment?.id) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Attachment was created but could not be retrieved' + return runLinearWrite( + entry, + options.signal, + async (client) => { + const result = await client.createAttachment({ + id: input.id, + issueId, + title: input.title, + url: input.url + }) + if (!result.success) { + throw new LinearWriteFailure('failed', 'Failed to create attachment') + } + options.signal?.throwIfAborted() + const attachment = await confirmLinearWrite( + 'Attachment was created but could not be retrieved', + async () => result.attachment ) - } - const record = await confirmLinearWrite( - 'Attachment was created but could not be retrieved', - () => readAttachmentWriteRecord(client, attachment.id) - ) - if (!record) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Attachment was created but could not be retrieved' + if (!attachment?.id) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Attachment was created but could not be retrieved' + ) + } + const record = await confirmLinearWrite( + 'Attachment was created but could not be retrieved', + () => readAttachmentWriteRecord(client, attachment.id, options.signal) ) - } - return record - }) + if (!record) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Attachment was created but could not be retrieved' + ) + } + return record + }, + options.beforeInvoke + ) } async function readCommentWriteRecord( client: LinearClient, - id: string + id: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const result = await client.client.rawRequest( COMMENT_BY_UUID_QUERY, { id } @@ -218,8 +239,10 @@ async function readCommentWriteRecord( async function readAttachmentWriteRecord( client: LinearClient, - id: string + id: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const result = await client.client.rawRequest( ATTACHMENT_BY_UUID_QUERY, { id } diff --git a/src/main/linear/linear-issue-lookups.ts b/src/main/linear/linear-issue-lookups.ts index 466dae45399..cb526eba0f5 100644 --- a/src/main/linear/linear-issue-lookups.ts +++ b/src/main/linear/linear-issue-lookups.ts @@ -95,21 +95,26 @@ async function readIssueWithSignal( export async function getIssueByUuidForAgent( id: string, - workspaceId?: string | null + workspaceId?: string | null, + signal?: AbortSignal ): Promise { - const entry = getClients(workspaceId)[0] + const entry = getClients(workspaceId, signal)[0] if (!entry) { return null } - return runLinearLookup(entry, async () => { - const result = await entry.client.client.rawRequest< - LinearIssueByUuidResponse, - LinearRawVariables - >(ISSUE_BY_UUID_QUERY, { id }) - const issue = result.data?.issue ?? null - return issue ? mapRawIssueWriteRecord(issue) : null - }) + return runLinearLookup( + entry, + async () => { + const result = await entry.client.client.rawRequest< + LinearIssueByUuidResponse, + LinearRawVariables + >(ISSUE_BY_UUID_QUERY, { id }) + const issue = result.data?.issue ?? null + return issue ? mapRawIssueWriteRecord(issue) : null + }, + signal + ) } export async function getCommentByUuidForAgent( @@ -139,29 +144,35 @@ export async function getCommentByUuidForAgent( export async function getAttachmentByUuidForAgent( id: string, - workspaceId?: string | null + workspaceId?: string | null, + signal?: AbortSignal ): Promise { - const entry = getClients(workspaceId)[0] + const entry = getClients(workspaceId, signal)[0] if (!entry) { return null } - return runLinearLookup(entry, async () => { - const result = await entry.client.client.rawRequest< - LinearAttachmentByUuidResponse, - LinearRawVariables - >(ATTACHMENT_BY_UUID_QUERY, { id }) - const attachment = result.data?.attachment - return attachment ? mapRawAttachmentWriteRecord(attachment) : null - }) + return runLinearLookup( + entry, + async () => { + const result = await entry.client.client.rawRequest< + LinearAttachmentByUuidResponse, + LinearRawVariables + >(ATTACHMENT_BY_UUID_QUERY, { id }) + const attachment = result.data?.attachment + return attachment ? mapRawAttachmentWriteRecord(attachment) : null + }, + signal + ) } export async function getIssueCommentThreadRoot( issueId: string, commentId: string, - workspaceId?: string | null + workspaceId?: string | null, + signal?: AbortSignal ): Promise<{ id: string; parentId: string | null } | null> { - const comment = await getCommentByUuidForAgent(commentId, workspaceId) + const comment = await getCommentByUuidForAgent(commentId, workspaceId, signal) if (!comment || comment.issue.id !== issueId) { return null } diff --git a/src/main/linear/linear-issue-mutations.ts b/src/main/linear/linear-issue-mutations.ts index bab149d3647..3c974069be3 100644 --- a/src/main/linear/linear-issue-mutations.ts +++ b/src/main/linear/linear-issue-mutations.ts @@ -103,6 +103,7 @@ export async function createIssueForAgent( dueDate?: string | null labelIds?: string[] signal?: AbortSignal + beforeInvoke?: () => void } ): Promise { const entry = getClients(workspaceId)[0] @@ -110,41 +111,49 @@ export async function createIssueForAgent( throw new LinearWriteFailure('failed', 'Not connected to Linear') } - return runLinearWrite(entry, options.signal, async (client) => { - const result = await client.createIssue({ - id: options.id, - teamId, - title, - ...(description ? { description } : {}), - ...(options.parentId ? { parentId: options.parentId } : {}), - ...(options.projectId ? { projectId: options.projectId } : {}), - ...(options.stateId ? { stateId: options.stateId } : {}), - ...(options.assigneeId !== undefined ? { assigneeId: options.assigneeId } : {}), - ...(options.priority !== undefined ? { priority: options.priority } : {}), - ...(options.estimate !== undefined ? { estimate: options.estimate } : {}), - ...(options.dueDate !== undefined ? { dueDate: options.dueDate } : {}), - ...(options.labelIds !== undefined ? { labelIds: options.labelIds } : {}) - }) - if (!result.success) { - throw new LinearWriteFailure('failed', 'Linear create failed') - } - const issue = await confirmLinearWrite( - 'Issue was created but could not be retrieved', - async () => result.issue - ) - if (!issue?.id) { - throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved') - } - return confirmLinearWrite('Issue was created but could not be retrieved', () => - getCreatedIssueRecord(issue.id, client) - ) - }) + return runLinearWrite( + entry, + options.signal, + async (client) => { + const result = await client.createIssue({ + id: options.id, + teamId, + title, + ...(description ? { description } : {}), + ...(options.parentId ? { parentId: options.parentId } : {}), + ...(options.projectId ? { projectId: options.projectId } : {}), + ...(options.stateId ? { stateId: options.stateId } : {}), + ...(options.assigneeId !== undefined ? { assigneeId: options.assigneeId } : {}), + ...(options.priority !== undefined ? { priority: options.priority } : {}), + ...(options.estimate !== undefined ? { estimate: options.estimate } : {}), + ...(options.dueDate !== undefined ? { dueDate: options.dueDate } : {}), + ...(options.labelIds !== undefined ? { labelIds: options.labelIds } : {}) + }) + if (!result.success) { + throw new LinearWriteFailure('failed', 'Linear create failed') + } + options.signal?.throwIfAborted() + const issue = await confirmLinearWrite( + 'Issue was created but could not be retrieved', + async () => result.issue + ) + if (!issue?.id) { + throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved') + } + return confirmLinearWrite('Issue was created but could not be retrieved', () => + getCreatedIssueRecord(issue.id, client, options.signal) + ) + }, + options.beforeInvoke + ) } async function getCreatedIssueRecord( issueId: string, - client: LinearClient + client: LinearClient, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const result = await client.client.rawRequest( ISSUE_BY_UUID_QUERY, { id: issueId } @@ -224,51 +233,56 @@ export async function updateIssueForAgent( id: string, updates: LinearIssueUpdate, workspaceId: string, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; beforeInvoke?: () => void } = {} ): Promise { const entry = getClients(workspaceId)[0] if (!entry) { throw new LinearWriteFailure('failed', 'Not connected to Linear') } - return runLinearWrite(entry, options.signal, async (client) => { - const payload: Record = {} - if (updates.stateId !== undefined) { - payload.stateId = updates.stateId - } - if (updates.title !== undefined) { - payload.title = updates.title - } - if (updates.description !== undefined) { - payload.description = updates.description - } - if (updates.assigneeId !== undefined) { - payload.assigneeId = updates.assigneeId - } - if (updates.priority !== undefined) { - payload.priority = updates.priority - } - if (updates.estimate !== undefined) { - payload.estimate = updates.estimate - } - if (updates.dueDate !== undefined) { - payload.dueDate = updates.dueDate - } - if (updates.labelIds !== undefined) { - payload.labelIds = updates.labelIds - } - if (updates.projectId !== undefined) { - payload.projectId = updates.projectId - } - if (updates.parentId !== undefined) { - payload.parentId = updates.parentId - } - const result = await client.updateIssue(id, payload) - if (!result.success) { - throw new LinearWriteFailure('failed', 'Linear update failed') - } - return confirmLinearWrite('Issue was updated but could not be retrieved', () => - getCreatedIssueRecord(id, client) - ) - }) + return runLinearWrite( + entry, + options.signal, + async (client) => { + const payload: Record = {} + if (updates.stateId !== undefined) { + payload.stateId = updates.stateId + } + if (updates.title !== undefined) { + payload.title = updates.title + } + if (updates.description !== undefined) { + payload.description = updates.description + } + if (updates.assigneeId !== undefined) { + payload.assigneeId = updates.assigneeId + } + if (updates.priority !== undefined) { + payload.priority = updates.priority + } + if (updates.estimate !== undefined) { + payload.estimate = updates.estimate + } + if (updates.dueDate !== undefined) { + payload.dueDate = updates.dueDate + } + if (updates.labelIds !== undefined) { + payload.labelIds = updates.labelIds + } + if (updates.projectId !== undefined) { + payload.projectId = updates.projectId + } + if (updates.parentId !== undefined) { + payload.parentId = updates.parentId + } + const result = await client.updateIssue(id, payload) + if (!result.success) { + throw new LinearWriteFailure('failed', 'Linear update failed') + } + return confirmLinearWrite('Issue was updated but could not be retrieved', () => + getCreatedIssueRecord(id, client, options.signal) + ) + }, + options.beforeInvoke + ) } diff --git a/src/main/linear/linear-project-cancellation.test.ts b/src/main/linear/linear-project-cancellation.test.ts new file mode 100644 index 00000000000..19ca19bd0c0 --- /dev/null +++ b/src/main/linear/linear-project-cancellation.test.ts @@ -0,0 +1,134 @@ +import { setImmediate } from 'node:timers/promises' +import { beforeEach, expect, it, vi } from 'vitest' +import { getProject, listProjects, listProjectsByExactName } from './linear-project-queries' +import { listProjectTeams } from './linear-project-team-queries' +import { acquire, release } from './linear-request-concurrency' + +const { provider } = vi.hoisted(() => ({ provider: vi.fn() })) +vi.mock('./client', () => ({ + getClients: (_workspaceId: string, signal?: AbortSignal) => [ + { + workspace: { id: 'workspace-1', organizationName: 'Workspace' }, + client: { client: { rawRequest: () => provider(signal) } } + } + ], + isAuthError: () => false +})) +vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() })) + +const project = { id: 'project-1', name: 'Project' } +const teams = [{ id: 'team-1', name: 'Team', key: 'TM' }] +const connection = { nodes: [project], pageInfo: { hasNextPage: false } } +const response = { + data: { + project: { ...project, teams: { nodes: teams, pageInfo: { hasNextPage: false } } }, + projects: connection, + searchProjects: connection + } +} +const readers = [ + { + name: 'collection', + read: (force = false, signal?: AbortSignal) => + listProjects(undefined, 20, 'workspace-1', force, signal), + expected: { items: [project] } + }, + { + name: 'exact name', + read: (force = false, signal?: AbortSignal) => + listProjectsByExactName('Project', 'workspace-1', force, signal), + expected: [project] + }, + { + name: 'detail', + read: (force = false, signal?: AbortSignal) => + getProject('project-1', 'workspace-1', force, signal), + expected: project + }, + { + name: 'teams', + read: (force = false, signal?: AbortSignal) => + listProjectTeams('project-1', 'workspace-1', force, signal), + expected: teams + } +] + +beforeEach(() => vi.clearAllMocks()) + +it.each(readers)( + '$name keeps cancellation request-owned and preserves shared refreshes', + async ({ read, expected }) => { + for (const signalledFirst of [true, false]) { + const pending: { signal?: AbortSignal; finish: () => void }[] = [] + const observedAbort = vi.fn() + provider.mockImplementation( + (signal?: AbortSignal) => + new Promise((resolve, reject) => { + const onAbort = () => { + observedAbort() + reject(signal?.reason) + } + signal?.addEventListener('abort', onAbort, { once: true }) + pending.push({ + signal, + finish: () => { + signal?.removeEventListener('abort', onAbort) + resolve(response) + } + }) + }) + ) + const caller = new AbortController() + const cancelled = new Error('write caller disconnected') + const first = read(signalledFirst, signalledFirst ? caller.signal : undefined) + const second = read(false, signalledFirst ? undefined : caller.signal) + const shared = read() + const signalled = signalledFirst ? first : second + const independent = signalledFirst ? second : first + const outcome = signalled.catch((error: unknown) => error) + const settled = Promise.allSettled([first, second, shared]) + try { + await setImmediate() + expect(pending).toHaveLength(2) + expect(pending.filter((request) => request.signal === caller.signal)).toHaveLength(1) + caller.abort(cancelled) + await expect(outcome).resolves.toBe(cancelled) + expect(observedAbort).toHaveBeenCalledTimes(1) + + // A forced shared refresh replaces the shared read, never the request-owned read. + const refreshed = read(true) + const joinedRefresh = read() + const refreshedSettled = Promise.allSettled([refreshed, joinedRefresh]) + try { + await setImmediate() + expect(pending).toHaveLength(3) + pending[2].finish() + await expect(refreshed).resolves.toMatchObject(expected) + await expect(joinedRefresh).resolves.toMatchObject(expected) + for (const request of pending) { + request.finish() + } + await expect(independent).resolves.toMatchObject(expected) + await expect(shared).resolves.toMatchObject(expected) + } finally { + for (const request of pending) { + request.finish() + } + await refreshedSettled + } + } finally { + caller.abort(cancelled) + for (const request of pending) { + request.finish() + } + await settled + } + + // All four real queue permits remain available after both owners settle. + await Promise.all([acquire(), acquire(), acquire(), acquire()]) + for (let i = 0; i < 4; i++) { + release() + } + } + } +) diff --git a/src/main/linear/linear-project-collection-read.ts b/src/main/linear/linear-project-collection-read.ts index d9924e46346..d7e1ca0c678 100644 --- a/src/main/linear/linear-project-collection-read.ts +++ b/src/main/linear/linear-project-collection-read.ts @@ -17,20 +17,22 @@ export async function readCollection( key: string, workspaceId: LinearWorkspaceSelection | null | undefined, load: (entry: LinearClientForWorkspace) => Promise>, - force = false + force = false, + signal?: AbortSignal ): Promise> { return coalesce( key, async () => { - const entries = getClients(workspaceId) + const entries = getClients(workspaceId, signal) if (entries.length === 0) { return { items: [] } } const results = await Promise.all( entries.map(async (entry) => { - await acquire() + await acquire(signal) try { + signal?.throwIfAborted() return await load(entry) } catch (error) { if (isAuthError(error)) { @@ -56,7 +58,8 @@ export async function readCollection( hasMore: results.some((result) => result.hasMore) } }, - force + force, + signal ) } diff --git a/src/main/linear/linear-project-models.ts b/src/main/linear/linear-project-models.ts index 38371e70b1f..ff6ed8e0b80 100644 --- a/src/main/linear/linear-project-models.ts +++ b/src/main/linear/linear-project-models.ts @@ -26,7 +26,16 @@ export function clampLimit(limit = 20): number { return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) } -export function coalesce(key: string, load: () => Promise, force = false): Promise { +export function coalesce( + key: string, + load: () => Promise, + force = false, + signal?: AbortSignal +): Promise { + // Request-owned cancellation must neither join nor replace independent readers' work. + if (signal !== undefined) { + return load() + } const existing = inFlight.get(key) as Promise | undefined if (existing && !force) { return existing diff --git a/src/main/linear/linear-project-queries.ts b/src/main/linear/linear-project-queries.ts index 588e899b52d..8920a35e739 100644 --- a/src/main/linear/linear-project-queries.ts +++ b/src/main/linear/linear-project-queries.ts @@ -34,7 +34,8 @@ export async function listProjects( query: string | undefined, limit = 20, workspaceId?: LinearWorkspaceSelection | null, - force = false + force = false, + signal?: AbortSignal ): Promise> { const first = clampLimit(limit) const trimmed = query?.trim() @@ -54,14 +55,16 @@ export async function listProjects( hasMore: !!connection?.pageInfo?.hasNextPage } }, - force + force, + signal ) } export async function listProjectsByExactName( name: string, workspaceId: LinearConcreteWorkspaceId, - force = false + force = false, + signal?: AbortSignal ): Promise { const projectName = name.trim() if (!projectName) { @@ -73,16 +76,17 @@ export async function listProjectsByExactName( return coalesce( key, async () => { - const entries = getClients(concreteWorkspaceId) + const entries = getClients(concreteWorkspaceId, signal) const entry = entries[0] if (!entry) { return [] } - await acquire() + await acquire(signal) try { const matches: LinearProjectSummary[] = [] let after: string | undefined while (true) { + signal?.throwIfAborted() const result = await entry.client.client.rawRequest< ProjectConnectionResponse, LinearRawVariables @@ -118,14 +122,16 @@ export async function listProjectsByExactName( release() } }, - force + force, + signal ) } export async function getProject( id: string, workspaceId: LinearConcreteWorkspaceId, - force = false + force = false, + signal?: AbortSignal ): Promise { const projectId = id.trim() const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) @@ -136,13 +142,14 @@ export async function getProject( return coalesce( key, async () => { - const entries = getClients(concreteWorkspaceId) + const entries = getClients(concreteWorkspaceId, signal) const entry = entries[0] if (!entry) { return null } - await acquire() + await acquire(signal) try { + signal?.throwIfAborted() const result = await entry.client.client.rawRequest< ProjectConnectionResponse, LinearRawVariables @@ -159,7 +166,8 @@ export async function getProject( release() } }, - force + force, + signal ) } diff --git a/src/main/linear/linear-project-team-queries.ts b/src/main/linear/linear-project-team-queries.ts index 569dd130220..56dc948d884 100644 --- a/src/main/linear/linear-project-team-queries.ts +++ b/src/main/linear/linear-project-team-queries.ts @@ -10,7 +10,8 @@ import { coalesce, normalizeConcreteWorkspaceId } from './linear-project-models' export async function listProjectTeams( projectId: string, workspaceId: LinearConcreteWorkspaceId, - force = false + force = false, + signal?: AbortSignal ): Promise> { const id = projectId.trim() if (!id) { @@ -21,15 +22,16 @@ export async function listProjectTeams( return coalesce( key, async () => { - const entry = getClients(concreteWorkspaceId)[0] + const entry = getClients(concreteWorkspaceId, signal)[0] if (!entry) { return [] } const teams: NonNullable = [] let after: string | undefined - await acquire() + await acquire(signal) try { while (true) { + signal?.throwIfAborted() const result = await entry.client.client.rawRequest< ProjectTeamsResponse, LinearRawVariables @@ -72,6 +74,7 @@ export async function listProjectTeams( release() } }, - force + force, + signal ) } diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index b0e6da1613a..f0f5afb4f0a 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -39,10 +39,13 @@ type TeamStateNode = { } export async function fetchAllTeamsForWorkspace( - entry: LinearClientForWorkspace + entry: LinearClientForWorkspace, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() let page = await entry.client.teams({ first: TEAM_PAGE_SIZE }) while (page.pageInfo.hasNextPage) { + signal?.throwIfAborted() await page.fetchNext() } return page.nodes.map((t) => ({ @@ -59,11 +62,15 @@ export async function fetchAllTeamsForWorkspace( })) } -export async function fetchAllTeamStates(team: { - states: (variables?: { first?: number }) => Promise> -}): Promise { +export async function fetchAllTeamStates( + team: { + states: (variables?: { first?: number }) => Promise> + }, + signal?: AbortSignal +): Promise { const states = await team.states({ first: TEAM_PAGE_SIZE }) while (states.pageInfo.hasNextPage) { + signal?.throwIfAborted() await states.fetchNext() } return states.nodes @@ -77,11 +84,15 @@ export async function fetchAllTeamStates(team: { .sort((a, b) => a.position - b.position) } -export async function fetchAllTeamLabels(team: { - labels: (variables?: { first?: number }) => Promise> -}): Promise { +export async function fetchAllTeamLabels( + team: { + labels: (variables?: { first?: number }) => Promise> + }, + signal?: AbortSignal +): Promise { const labels = await team.labels({ first: TEAM_PAGE_SIZE }) while (labels.pageInfo.hasNextPage) { + signal?.throwIfAborted() await labels.fetchNext() } return labels.nodes @@ -89,11 +100,15 @@ export async function fetchAllTeamLabels(team: { .map((l) => ({ id: l.id, name: l.name, color: l.color })) } -export async function fetchAllTeamMembers(team: { - members: (variables?: { first?: number }) => Promise> -}): Promise { +export async function fetchAllTeamMembers( + team: { + members: (variables?: { first?: number }) => Promise> + }, + signal?: AbortSignal +): Promise { const members = await team.members({ first: TEAM_PAGE_SIZE }) while (members.pageInfo.hasNextPage) { + signal?.throwIfAborted() await members.fetchNext() } return members.nodes.map((m) => ({ diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index 0d86b04f3b9..0adac32dd95 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -248,7 +248,7 @@ describe('Linear teams', () => { { id: 'label-3', name: 'Backend', color: '#ff0000' } ]) expect(connection.fetchNext).toHaveBeenCalledTimes(3) - expect(getClients).toHaveBeenCalledWith('workspace-1') + expect(getClients).toHaveBeenCalledWith('workspace-1', undefined) expect(entry.client.team).toHaveBeenCalledWith('team-1') expect(labels).toHaveBeenCalledWith({ first: 100 }) } diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index d10974ccb3c..a14cc2de026 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -49,18 +49,19 @@ export async function listTeams( } export async function listTeamsOrThrow( - workspaceId?: LinearWorkspaceSelection | null + workspaceId?: LinearWorkspaceSelection | null, + signal?: AbortSignal ): Promise { - const entries = getClients(workspaceId) + const entries = getClients(workspaceId, signal) if (entries.length === 0) { return [] } const results = await Promise.all( entries.map(async (entry) => { - await acquire() + await acquire(signal) try { - return await fetchAllTeamsForWorkspace(entry) + return await fetchAllTeamsForWorkspace(entry, signal) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) @@ -114,17 +115,21 @@ export async function listTeamsForAgent( async function readTeamResource( teamId: string, workspaceId: string | null | undefined, - fetchAll: (team: Team) => Promise, - fallbackWarning?: string + fetchAll: (team: Team, signal?: AbortSignal) => Promise, + fallbackWarning?: string, + signal?: AbortSignal ): Promise { - const entry = getClients(workspaceId)[0] + const entry = getClients(workspaceId, signal)[0] if (!entry) { return [] } - await acquire() + await acquire(signal) try { - return await fetchAll(await entry.client.team(teamId)) + signal?.throwIfAborted() + const team = await entry.client.team(teamId) + signal?.throwIfAborted() + return await fetchAll(team, signal) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) @@ -149,9 +154,10 @@ export async function getTeamStates( export async function getTeamStatesOrThrow( teamId: string, - workspaceId?: string | null + workspaceId?: string | null, + signal?: AbortSignal ): Promise { - return readTeamResource(teamId, workspaceId, fetchAllTeamStates) + return readTeamResource(teamId, workspaceId, fetchAllTeamStates, undefined, signal) } export async function getTeamLabels( @@ -163,9 +169,10 @@ export async function getTeamLabels( export async function getTeamLabelsOrThrow( teamId: string, - workspaceId?: string | null + workspaceId?: string | null, + signal?: AbortSignal ): Promise { - return readTeamResource(teamId, workspaceId, fetchAllTeamLabels) + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, undefined, signal) } export async function getTeamMembers( @@ -177,21 +184,24 @@ export async function getTeamMembers( export async function getTeamMembersOrThrow( teamId: string, - workspaceId?: string | null + workspaceId?: string | null, + signal?: AbortSignal ): Promise { - return readTeamResource(teamId, workspaceId, fetchAllTeamMembers) + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, undefined, signal) } export async function getViewerForWorkspaceOrThrow( - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> { - const entry = getClients(workspaceId)[0] + const entry = getClients(workspaceId, signal)[0] if (!entry) { throw new Error('Not connected to Linear') } - await acquire() + await acquire(signal) try { + signal?.throwIfAborted() const viewer = await entry.client.viewer return { id: viewer.id, diff --git a/src/main/runtime/linear-save-issue.test.ts b/src/main/runtime/linear-save-issue.test.ts index 02630a7452b..4c5b651d612 100644 --- a/src/main/runtime/linear-save-issue.test.ts +++ b/src/main/runtime/linear-save-issue.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { LINEAR_WRITE_BODY_CAP } from '../../shared/linear/agent-access' +import * as mutations from '../linear/linear-issue-mutations' import * as linearTeams from '../linear/teams' import { OrcaRuntimeService } from './orca-runtime' @@ -43,8 +44,12 @@ describe('Linear save issue', () => { meta: { workspaceId: 'workspace-1', writeId: 'write-1', deduplicated: false } }) + const signal = new AbortController().signal await expect( - runtime.linearSaveIssue({ team: 'ENG', title: 'New issue', workspaceId: 'workspace-1' }) + runtime.linearSaveIssue( + { team: 'ENG', title: 'New issue', workspaceId: 'workspace-1' }, + signal + ) ).resolves.toMatchObject({ meta: { created: true } }) expect(create).toHaveBeenCalledWith( @@ -52,7 +57,8 @@ describe('Linear save issue', () => { teamInput: 'ENG', title: 'New issue', workspaceId: 'workspace-1' - }) + }), + signal ) }) @@ -83,7 +89,7 @@ describe('Linear save issue', () => { it('does not send a mutation or confirmation read when every field is already set', async () => { const runtime = new OrcaRuntimeService() - const runWrite = vi.fn() + const mutation = vi.spyOn(mutations, 'updateIssueForAgent') const notify = vi.fn().mockResolvedValue(undefined) Object.assign(runtime, { resolveLinearAgentWriteTarget: vi @@ -91,7 +97,6 @@ describe('Linear save issue', () => { .mockResolvedValue({ issue, workspaceId: 'workspace-1' }), readLinearAgentIssueWriteRecord: vi.fn().mockResolvedValue(issue), buildLinearSaveUpdate: vi.fn().mockResolvedValue({ title: issue.title }), - runLinearAgentWrite: runWrite, notifyLinearLinkedIssueUpdated: notify }) @@ -99,7 +104,8 @@ describe('Linear save issue', () => { runtime.linearSaveIssue({ input: issue.identifier, title: issue.title }) ).resolves.toMatchObject({ issue, meta: { created: false } }) - expect(runWrite).not.toHaveBeenCalled() + expect(mutation).not.toHaveBeenCalled() + expect(runtime.readLinearAgentIssueWriteRecord).toHaveBeenCalledTimes(1) expect(notify).toHaveBeenCalledWith('workspace-1', issue.identifier) }) diff --git a/src/main/runtime/rpc/methods/linear-agent-access.test.ts b/src/main/runtime/rpc/methods/linear-agent-access.test.ts index 313f0ae98fc..ea24c69064c 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.test.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.test.ts @@ -5,7 +5,11 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import { OrcaRuntimeService } from '../../orca-runtime' import { LinearWriteFailure } from '../../../linear/linear-issue-write-support' -import { sanitizeLinearErrorMessage } from '../../../linear/issue-context/errors' +import { + linearError, + sanitizeLinearErrorMessage, + type LinearAgentAccessError +} from '../../../linear/issue-context/errors' import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -59,6 +63,7 @@ describe('Linear agent access RPC methods', () => { linearSaveIssue: vi.fn().mockResolvedValue({ ok: true }), linearIssueCreate: vi.fn().mockResolvedValue({ ok: true }) } as unknown as OrcaRuntimeService + const signal = new AbortController().signal const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS }) const setStateResponse = await dispatcher.dispatch( @@ -66,7 +71,8 @@ describe('Linear agent access RPC methods', () => { input: 'ENG-1', to: 'In Review', workspaceId: 'workspace-1' - }) + }), + { signal } ) const teamListResponse = await dispatcher.dispatch( makeRequest('linear.agentTeamList', { workspaceId: 'all' }) @@ -86,14 +92,16 @@ describe('Linear agent access RPC methods', () => { teamInput: 'ENG', limit: 10, workspaceId: 'workspace-1' - }) + }), + { signal } ) const projectListResponse = await dispatcher.dispatch( makeRequest('linear.agentProjectList', { query: 'launch', limit: 10, workspaceId: 'all' - }) + }), + { signal } ) const taskUpdateResponse = await dispatcher.dispatch( makeRequest('linear.issueUpdateTask', { @@ -101,7 +109,8 @@ describe('Linear agent access RPC methods', () => { operation: 'dueDate', dueDate: '2026-06-30', workspaceId: 'workspace-1' - }) + }), + { signal } ) const relationResponse = await dispatcher.dispatch( makeRequest('linear.issueRelationWrite', { @@ -110,7 +119,8 @@ describe('Linear agent access RPC methods', () => { relationship: 'blockedBy', operation: 'add', workspaceId: 'workspace-1' - }) + }), + { signal } ) const commentResponse = await dispatcher.dispatch( makeRequest('linear.issueAddComment', { @@ -119,7 +129,8 @@ describe('Linear agent access RPC methods', () => { replyTo: 'comment-1', writeId: '11111111-1111-4111-8111-111111111111', workspaceId: 'workspace-1' - }) + }), + { signal } ) const attachResponse = await dispatcher.dispatch( makeRequest('linear.issueAttachLink', { @@ -128,7 +139,8 @@ describe('Linear agent access RPC methods', () => { title: 'Review', writeId: '22222222-2222-4222-8222-222222222222', workspaceId: 'workspace-1' - }) + }), + { signal } ) const createResponse = await dispatcher.dispatch( makeRequest('linear.issueCreate', { @@ -140,7 +152,8 @@ describe('Linear agent access RPC methods', () => { parentInput: 'ENG-1', writeId: '33333333-3333-4333-8333-333333333333', workspaceId: 'workspace-1' - }) + }), + { signal } ) const saveResponse = await dispatcher.dispatch( makeRequest('linear.saveIssue', { @@ -150,7 +163,8 @@ describe('Linear agent access RPC methods', () => { labels: ['Bug'], project: null, workspaceId: 'workspace-1' - }) + }), + { signal } ) expect(setStateResponse.ok).toBe(true) @@ -166,11 +180,14 @@ describe('Linear agent access RPC methods', () => { expect(attachResponse.ok).toBe(true) expect(createResponse.ok).toBe(true) expect(saveResponse.ok).toBe(true) - expect(runtime.linearIssueSetState).toHaveBeenCalledWith({ - input: 'ENG-1', - to: 'In Review', - workspaceId: 'workspace-1' - }) + expect(runtime.linearIssueSetState).toHaveBeenCalledWith( + { + input: 'ENG-1', + to: 'In Review', + workspaceId: 'workspace-1' + }, + signal + ) expect(runtime.linearTeamListForAgents).toHaveBeenCalledWith({ workspaceId: 'all' }) expect(runtime.linearTeamMembersForAgents).toHaveBeenCalledWith({ teamInput: 'ENG', @@ -195,52 +212,70 @@ describe('Linear agent access RPC methods', () => { limit: 10, workspaceId: 'all' }) - expect(runtime.linearIssueUpdateTask).toHaveBeenCalledWith({ - input: 'ENG-1', - operation: 'dueDate', - dueDate: '2026-06-30', - workspaceId: 'workspace-1' - }) - expect(runtime.linearIssueRelationWrite).toHaveBeenCalledWith({ - input: 'ENG-1', - relatedInput: 'ENG-2', - relationship: 'blockedBy', - operation: 'add', - workspaceId: 'workspace-1' - }) - expect(runtime.linearIssueAddComment).toHaveBeenCalledWith({ - input: 'ENG-1', - body: 'Done', - replyTo: 'comment-1', - writeId: '11111111-1111-4111-8111-111111111111', - workspaceId: 'workspace-1' - }) - expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith({ - input: 'ENG-1', - url: 'https://example.com/review/1', - title: 'Review', - writeId: '22222222-2222-4222-8222-222222222222', - workspaceId: 'workspace-1' - }) - expect(runtime.linearIssueCreate).toHaveBeenCalledWith({ - title: 'Follow up', - body: 'Details', - teamInput: 'ENG', - projectInput: 'project-1', - priority: 2, - parentInput: 'ENG-1', - writeId: '33333333-3333-4333-8333-333333333333', - workspaceId: 'workspace-1' - }) - expect(runtime.linearSaveIssue).toHaveBeenCalledWith({ - input: 'ENG-1', - title: 'Updated title', - assignee: null, - labels: ['Bug'], - project: null, - workspaceId: 'workspace-1', - writeId: undefined - }) + expect(runtime.linearIssueUpdateTask).toHaveBeenCalledWith( + { + input: 'ENG-1', + operation: 'dueDate', + dueDate: '2026-06-30', + workspaceId: 'workspace-1' + }, + signal + ) + expect(runtime.linearIssueRelationWrite).toHaveBeenCalledWith( + { + input: 'ENG-1', + relatedInput: 'ENG-2', + relationship: 'blockedBy', + operation: 'add', + workspaceId: 'workspace-1' + }, + signal + ) + expect(runtime.linearIssueAddComment).toHaveBeenCalledWith( + { + input: 'ENG-1', + body: 'Done', + replyTo: 'comment-1', + writeId: '11111111-1111-4111-8111-111111111111', + workspaceId: 'workspace-1' + }, + signal + ) + expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith( + { + input: 'ENG-1', + url: 'https://example.com/review/1', + title: 'Review', + writeId: '22222222-2222-4222-8222-222222222222', + workspaceId: 'workspace-1' + }, + signal + ) + expect(runtime.linearIssueCreate).toHaveBeenCalledWith( + { + title: 'Follow up', + body: 'Details', + teamInput: 'ENG', + projectInput: 'project-1', + priority: 2, + parentInput: 'ENG-1', + writeId: '33333333-3333-4333-8333-333333333333', + workspaceId: 'workspace-1' + }, + signal + ) + expect(runtime.linearSaveIssue).toHaveBeenCalledWith( + { + input: 'ENG-1', + title: 'Updated title', + assignee: null, + labels: ['Bug'], + project: null, + workspaceId: 'workspace-1', + writeId: undefined + }, + signal + ) }) it('rejects malformed write ids before the runtime is called', async () => { @@ -343,11 +378,21 @@ describe('Linear agent access RPC methods', () => { }) }) -type LinearWriteRunner = { - runLinearAgentWrite( - write: (signal: AbortSignal) => Promise, - unconfirmed: (cause?: string) => Error - ): Promise +function runWrite( + runtime: OrcaRuntimeService, + write: (signal: AbortSignal) => Promise, + unconfirmed: (cause?: string) => LinearAgentAccessError +): Promise { + return runtime + .runLinearAgentAction( + async () => null, + async (_owner, options) => { + options.beforeInvoke() + return write(options.signal) + }, + (_owner, cause) => unconfirmed(cause) + ) + .then(({ result }) => result) } type LinearUnconfirmedBuilder = { @@ -356,14 +401,11 @@ type LinearUnconfirmedBuilder = { writeId: string, target: unknown, extra?: unknown - ): Error & { data?: { cause?: string; nextSteps?: string[] } } + ): LinearAgentAccessError & { data?: { cause?: string; nextSteps?: string[] } } resolveLinearAgentState(input: string, states: unknown[]): unknown linearCreatedIssueMatchesIntent(issue: unknown, intent: unknown): boolean linearSavedIssueMatchesIntent(issue: unknown, intent: unknown): Promise - notifyLinearLinkedIssueUpdated( - workspaceId: string, - identifier: string | readonly string[] - ): Promise + notifyLinearLinkedIssueUpdated(workspaceId: string, identifier: string | readonly string[]): void listResolvedWorktrees(): Promise } @@ -402,17 +444,17 @@ type LinearRetryLookupTester = { describe('Linear agent write recovery helpers', () => { it('keeps stable write failure codes while preserving the Linear provider message', async () => { const runtime = new OrcaRuntimeService() - const runner = runtime as unknown as LinearWriteRunner await expect( - runner.runLinearAgentWrite( + runWrite( + runtime, async () => { throw new LinearWriteFailure( 'failed', 'Linear rejected the state transition because the issue is archived.' ) }, - () => Object.assign(new Error('should not be used'), { code: 'linear_write_unconfirmed' }) + () => linearError('linear_write_unconfirmed', 'should not be used') ) ).rejects.toMatchObject({ code: 'linear_write_failed', @@ -422,7 +464,6 @@ describe('Linear agent write recovery helpers', () => { it('keeps pinned retry guidance for unconfirmed writes while adding sanitized cause text', async () => { const runtime = new OrcaRuntimeService() - const runner = runtime as unknown as LinearWriteRunner const builder = runtime as unknown as LinearUnconfirmedBuilder const writeId = '11111111-1111-4111-8111-111111111111' const target = { @@ -431,7 +472,8 @@ describe('Linear agent write recovery helpers', () => { } await expect( - runner.runLinearAgentWrite( + runWrite( + runtime, async () => { throw new LinearWriteFailure( 'unconfirmed', @@ -472,14 +514,9 @@ describe('Linear agent write recovery helpers', () => { try { const runtime = new OrcaRuntimeService() const write = vi.fn((_signal: AbortSignal) => new Promise(() => undefined)) - const unconfirmed = vi.fn(() => - Object.assign(new Error('unconfirmed'), { code: 'linear_write_unconfirmed' }) - ) + const unconfirmed = vi.fn(() => linearError('linear_write_unconfirmed', 'unconfirmed')) - const pending = (runtime as unknown as LinearWriteRunner).runLinearAgentWrite( - write, - unconfirmed - ) + const pending = runWrite(runtime, write, unconfirmed) const rejection = expect(pending).rejects.toMatchObject({ code: 'linear_write_unconfirmed' }) diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts index 63dfa85214a..2e5469570c0 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.ts @@ -35,8 +35,8 @@ export const LINEAR_AGENT_ACCESS_METHODS = [ name: 'linear.saveIssue', permission: 'workspace', params: LinearSaveIssue, - handler: async (params, { runtime }) => - runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) }) + handler: async (params, { runtime, signal }) => + runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) }, signal) }), defineMethod({ name: 'linear.agentSearchIssues', @@ -101,26 +101,29 @@ export const LINEAR_AGENT_ACCESS_METHODS = [ name: 'linear.issueSetState', permission: 'workspace', params: LinearIssueSetState, - handler: async (params, { runtime }) => runtime.linearIssueSetState(params) + handler: async (params, { runtime, signal }) => runtime.linearIssueSetState(params, signal) }), defineMethod({ name: 'linear.issueUpdateTask', permission: 'workspace', params: LinearIssueUpdateTask, - handler: async (params, { runtime }) => runtime.linearIssueUpdateTask(params) + handler: async (params, { runtime, signal }) => runtime.linearIssueUpdateTask(params, signal) }), defineMethod({ name: 'linear.issueRelationWrite', permission: 'workspace', params: LinearIssueRelationWrite, - handler: async (params, { runtime }) => runtime.linearIssueRelationWrite(params) + handler: async (params, { runtime, signal }) => runtime.linearIssueRelationWrite(params, signal) }), defineMethod({ name: 'linear.issueAddComment', permission: 'workspace', params: LinearIssueAddComment, - handler: async (params, { runtime }) => - runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) }) + handler: async (params, { runtime, signal }) => + runtime.linearIssueAddComment( + { ...params, writeId: parseLinearWriteId(params.writeId) }, + signal + ) }), defineMethod({ name: 'linear.commentEdit', @@ -138,14 +141,17 @@ export const LINEAR_AGENT_ACCESS_METHODS = [ name: 'linear.issueAttachLink', permission: 'workspace', params: LinearIssueAttachLink, - handler: async (params, { runtime }) => - runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) }) + handler: async (params, { runtime, signal }) => + runtime.linearIssueAttachLink( + { ...params, writeId: parseLinearWriteId(params.writeId) }, + signal + ) }), defineMethod({ name: 'linear.issueCreate', permission: 'workspace', params: LinearIssueCreate, - handler: async (params, { runtime }) => - runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) }) + handler: async (params, { runtime, signal }) => + runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) }, signal) }) ] diff --git a/src/main/runtime/rpc/methods/linear-agent-project-access.spec.ts b/src/main/runtime/rpc/methods/linear-agent-project-access.spec.ts index 77cb32b95a7..c24d2b1a31c 100644 --- a/src/main/runtime/rpc/methods/linear-agent-project-access.spec.ts +++ b/src/main/runtime/rpc/methods/linear-agent-project-access.spec.ts @@ -49,7 +49,11 @@ describe('Linear agent project access helpers', () => { workspaceId: 'workspace-1' }) ).resolves.toMatchObject({ id: '11111111-1111-4111-8111-111111111111' }) - expect(readById).toHaveBeenCalledWith('11111111-1111-4111-8111-111111111111', 'workspace-1') + expect(readById).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111', + 'workspace-1', + undefined + ) expect(readByName).not.toHaveBeenCalled() }) @@ -77,8 +81,8 @@ describe('Linear agent project access helpers', () => { await expect( tester.resolveLinearCreateProject(' launch ', { id: 'team-1', workspaceId: 'workspace-1' }) ).resolves.toMatchObject({ id: 'project-1' }) - expect(readByName).toHaveBeenCalledWith('launch', 'workspace-1') - expect(readExactName).toHaveBeenCalledWith('launch', 'workspace-1') + expect(readByName).toHaveBeenCalledWith('launch', 'workspace-1', undefined) + expect(readExactName).toHaveBeenCalledWith('launch', 'workspace-1', undefined) }) it('resolves Linear projects by URL slug without a paginated exact-name scan', async () => { diff --git a/src/main/runtime/runtime-linear-command-dependencies.ts b/src/main/runtime/runtime-linear-command-dependencies.ts index de7da63a104..847ca60a3bc 100644 --- a/src/main/runtime/runtime-linear-command-dependencies.ts +++ b/src/main/runtime/runtime-linear-command-dependencies.ts @@ -6,9 +6,13 @@ export { resolve } from 'node:path' export type { RuntimeClientEvent } from '../../shared/runtime-client-events' export type { LinearCurrentIssueContextHints, + LinearAttachRequest, LinearAttachResult, + LinearCommentAddRequest, LinearCommentAddResult, + LinearCommentDeleteRequest, LinearCommentDeleteResult, + LinearCommentEditRequest, LinearCommentEditResult, LinearCreateResult, LinearErrorCode, diff --git a/src/main/runtime/runtime-linear-comment-commands.ts b/src/main/runtime/runtime-linear-comment-commands.ts index 973a2a7b314..a518788e5e9 100644 --- a/src/main/runtime/runtime-linear-comment-commands.ts +++ b/src/main/runtime/runtime-linear-comment-commands.ts @@ -9,10 +9,12 @@ import { updateLinearIssueCommentForAgent, LinearWriteFailure } from './runtime-linear-command-dependencies' -import { linearError } from '../linear/issue-context/errors' -import type { LinearAgentAccessError } from '../linear/issue-context/errors' +import { linearError, type LinearAgentAccessError } from '../linear/issue-context/errors' import type { - LinearCurrentIssueContextHints, + LinearCommentAddRequest, + LinearAttachRequest, + LinearCommentEditRequest, + LinearCommentDeleteRequest, LinearAttachResult, LinearCommentAddResult, LinearCommentDeleteResult, @@ -21,147 +23,170 @@ import type { import { RuntimeLinearCreateCommands } from './runtime-linear-create-commands' import { getConnectedWorkspaces } from '../linear/issue-context/client' import { resolveWorkspaceSelector } from '../linear/issue-context/workspaces' -import { runLinearCommentAction } from './runtime-linear-comment-action' export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands { - async linearIssueAddComment(params: { - input?: string - current?: boolean - workspaceId?: string - body: string - replyTo?: string - writeId?: string - context?: LinearCurrentIssueContextHints - }): Promise { + async linearIssueAddComment( + params: LinearCommentAddRequest, + cancellation?: AbortSignal + ): Promise { if (params.body.length > LINEAR_WRITE_BODY_CAP) { throw linearError('linear_body_too_large', 'Linear comment body is too large.') } - const target = await this.resolveLinearAgentWriteTarget(params) - const parentId = params.replyTo - ? await this.resolveLinearCommentParentId(target.issue.id, params.replyTo, target.workspaceId) - : null const writeId = params.writeId ?? randomUUID() - const existing = - params.writeId !== undefined - ? await this.getMatchingLinearCommentWrite( - writeId, - target.issue.id, - parentId, - target.workspaceId, - true - ) - : null - if (existing) { - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearCommentResult(existing, target, params.body.length, writeId, true) - } + const { + owner: { target }, + result + } = await this.runLinearAgentAction( + async (signal) => { + const target = await this.resolveLinearAgentWriteTarget(params, signal) + const parentId = params.replyTo + ? await this.resolveLinearCommentParentId( + target.issue.id, + params.replyTo, + target.workspaceId, + signal + ) + : null + const existing = + params.writeId !== undefined + ? await this.getMatchingLinearCommentWrite( + writeId, + target.issue.id, + parentId, + target.workspaceId, + true, + signal + ) + : null + return { target, parentId, existing } + }, + async ({ target, parentId, existing }, options) => { + if (existing) { + return { record: existing, deduplicated: true } + } - try { - const comment = await this.runLinearAgentWrite( - (signal) => - addLinearIssueCommentForAgent(target.issue.id, params.body, target.workspaceId, { - id: writeId, - parentId, - signal - }), - (cause) => - this.linearCreateStyleUnconfirmed('comment', writeId, target, { - parentId, - bodyRequired: true, - cause - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearCommentResult(comment, target, params.body.length, writeId, false) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - const comment = await this.refetchLinearCommentAfterDuplicate( - writeId, - target.issue.id, - parentId, - target.workspaceId, - () => - this.linearCreateStyleUnconfirmed('comment', writeId, target, { + try { + const comment = await addLinearIssueCommentForAgent( + target.issue.id, + params.body, + target.workspaceId, + { + id: writeId, parentId, - bodyRequired: true - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearCommentResult(comment, target, params.body.length, writeId, true) - } - throw error - } + ...options + } + ) + + return { record: comment, deduplicated: false } + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + const comment = await this.refetchLinearCommentAfterDuplicate( + writeId, + target.issue.id, + parentId, + target.workspaceId, + () => + this.linearCreateStyleUnconfirmed('comment', writeId, target, { + parentId, + bodyRequired: true + }), + options.signal + ) + + return { record: comment, deduplicated: true } + } + throw error + } + }, + ({ target, parentId }, cause) => + this.linearCreateStyleUnconfirmed('comment', writeId, target, { + parentId, + bodyRequired: true, + cause + }), + cancellation + ) + void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearCommentResult( + result.record, + target, + params.body.length, + writeId, + result.deduplicated + ) } - async linearIssueAttachLink(params: { - input?: string - current?: boolean - workspaceId?: string - url: string - title?: string - writeId?: string - context?: LinearCurrentIssueContextHints - }): Promise { + async linearIssueAttachLink( + params: LinearAttachRequest, + cancellation?: AbortSignal + ): Promise { const url = this.parseLinearAttachmentUrl(params.url) - const target = await this.resolveLinearAgentWriteTarget(params) const writeId = params.writeId ?? randomUUID() const title = params.title?.trim() || this.defaultLinearAttachmentTitle(url) - const existing = - params.writeId !== undefined - ? await this.getMatchingLinearAttachmentWrite( - writeId, - target.issue.id, - target.workspaceId, - true - ) - : null - if (existing) { - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearAttachResult(existing, target, writeId, true) - } - try { - const attachment = await this.runLinearAgentWrite( - (signal) => - createLinearIssueAttachment( + const { + owner: { target }, + result + } = await this.runLinearAgentAction( + async (signal) => { + const target = await this.resolveLinearAgentWriteTarget(params, signal) + const existing = + params.writeId !== undefined + ? await this.getMatchingLinearAttachmentWrite( + writeId, + target.issue.id, + target.workspaceId, + true, + signal + ) + : null + return { target, existing } + }, + async ({ target, existing }, options) => { + if (existing) { + return { record: existing, deduplicated: true } + } + try { + const attachment = await createLinearIssueAttachment( target.issue.id, { id: writeId, title, url: url.toString() }, target.workspaceId, - { signal } - ), - (cause) => - this.linearCreateStyleUnconfirmed('attach', writeId, target, { - title, - url: url.toString(), - cause - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearAttachResult(attachment, target, writeId, false) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - const attachment = await this.refetchLinearAttachmentAfterDuplicate( - writeId, - target.issue.id, - target.workspaceId, - () => - this.linearCreateStyleUnconfirmed('attach', writeId, target, { - title, - url: url.toString() - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearAttachResult(attachment, target, writeId, true) - } - throw error - } + options + ) + + return { record: attachment, deduplicated: false } + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + const attachment = await this.refetchLinearAttachmentAfterDuplicate( + writeId, + target.issue.id, + target.workspaceId, + () => + this.linearCreateStyleUnconfirmed('attach', writeId, target, { + title, + url: url.toString() + }), + options.signal + ) + + return { record: attachment, deduplicated: true } + } + throw error + } + }, + ({ target }, cause) => + this.linearCreateStyleUnconfirmed('attach', writeId, target, { + title, + url: url.toString(), + cause + }), + cancellation + ) + void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + return this.linearAttachResult(result.record, target, writeId, result.deduplicated) } async linearCommentEdit( - params: { - commentId: string - body: string - workspaceId?: string - }, + params: LinearCommentEditRequest, signal?: AbortSignal ): Promise { if (params.body.length > LINEAR_WRITE_BODY_CAP) { @@ -170,15 +195,15 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands { const { owner: { comment, workspaceId }, result: edited - } = await runLinearCommentAction( + } = await this.runLinearAgentAction( (discoverySignal) => this.resolveLinearAgentComment(params, discoverySignal), (owner, options) => updateLinearIssueCommentForAgent(owner.comment.id, params.body, owner.workspaceId, options), (owner) => this.linearCommentChangeUnconfirmed('edit', owner.comment, owner.workspaceId), - (error) => this.mapLinearReadFailure(error), - signal + signal, + (error) => this.mapLinearCommentWriteFailure(error) ) - await this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier) + void this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier) return { comment: { id: edited.id, url: edited.url, parentId: edited.parentId }, issue: this.linearWriteIssueRef(comment.issue), @@ -187,23 +212,20 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands { } async linearCommentDelete( - params: { - commentId: string - workspaceId?: string - }, + params: LinearCommentDeleteRequest, signal?: AbortSignal ): Promise { const { owner: { comment, workspaceId } - } = await runLinearCommentAction( + } = await this.runLinearAgentAction( (discoverySignal) => this.resolveLinearAgentComment(params, discoverySignal), (owner, options) => deleteLinearIssueCommentForAgent(owner.comment.id, owner.workspaceId, options), (owner) => this.linearCommentChangeUnconfirmed('delete', owner.comment, owner.workspaceId), - (error) => this.mapLinearReadFailure(error), - signal + signal, + (error) => this.mapLinearCommentWriteFailure(error) ) - await this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier) + void this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier) return { comment: { id: comment.id, parentId: comment.parentId }, issue: this.linearWriteIssueRef(comment.issue), @@ -213,10 +235,7 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands { // A UUID has one owner; unrelated unavailable workspaces must not delay its mutation. public async resolveLinearAgentComment( - params: { - commentId: string - workspaceId?: string - }, + params: LinearCommentDeleteRequest, signal: AbortSignal ): Promise<{ comment: LinearAgentComment; workspaceId: string }> { if (!isLinearUuid(params.commentId)) { diff --git a/src/main/runtime/runtime-linear-comment-edit.test.ts b/src/main/runtime/runtime-linear-comment-edit.test.ts index 1d38d47f309..d4a7d0a6c5a 100644 --- a/src/main/runtime/runtime-linear-comment-edit.test.ts +++ b/src/main/runtime/runtime-linear-comment-edit.test.ts @@ -259,15 +259,17 @@ describe('Linear comment edit and delete', () => { } ) - it('keeps add uncertainty while preventing an expired queued add from invoking Linear', async () => { + it('reports no attempt while preventing an expired queued add from invoking Linear', async () => { vi.useFakeTimers() await Promise.all([acquire(), acquire(), acquire(), acquire()]) try { - const pending = createCommands().runLinearAgentWrite( - (signal) => addIssueCommentForAgent('issue-1', 'body', 'w2', { id: COMMENT_ID, signal }), + const pending = createCommands().runLinearAgentAction( + async () => null, + (_owner, options) => + addIssueCommentForAgent('issue-1', 'body', 'w2', { id: COMMENT_ID, ...options }), () => linearError('linear_write_unconfirmed', 'Add was not confirmed.') ) - const rejected = expect(pending).rejects.toMatchObject({ code: 'linear_write_unconfirmed' }) + const rejected = expect(pending).rejects.toMatchObject({ code: 'linear_timeout' }) await vi.advanceTimersByTimeAsync(25_000) await rejected expect(mocks.createComment).not.toHaveBeenCalled() diff --git a/src/main/runtime/runtime-linear-comment-lookup-commands.ts b/src/main/runtime/runtime-linear-comment-lookup-commands.ts index 99ce95ab840..30c9ecdce18 100644 --- a/src/main/runtime/runtime-linear-comment-lookup-commands.ts +++ b/src/main/runtime/runtime-linear-comment-lookup-commands.ts @@ -1,24 +1,26 @@ import { isLinearAuthError, - getLinearIssueCommentThreadRoot, - LinearWriteFailure + getLinearIssueCommentThreadRoot } from './runtime-linear-command-dependencies' import { LinearAgentAccessError, linearError, - sanitizeLinearErrorMessage, toLinearAccessError } from '../linear/issue-context/errors' +import { runLinearWriteAction } from './runtime-linear-write-action' +import type { LinearWriteFailure } from '../linear/linear-issue-write-support' import { RuntimeLinearWriteResultCommands } from './runtime-linear-write-result-commands' export class RuntimeLinearCommentLookupCommands extends RuntimeLinearWriteResultCommands { public async resolveLinearCommentParentId( issueId: string, commentId: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() try { - const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId) + const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId, signal) if (!root) { throw linearError( 'linear_invalid_parent', @@ -37,58 +39,31 @@ export class RuntimeLinearCommentLookupCommands extends RuntimeLinearWriteResult } } - public async runLinearAgentWrite( - write: (signal: AbortSignal) => Promise, - unconfirmed: (cause?: string) => LinearAgentAccessError - ): Promise { - const controller = new AbortController() - const writePromise = write(controller.signal) - writePromise.catch(() => undefined) - let timer: ReturnType | null = null - try { - return await Promise.race([ - writePromise, - new Promise((_resolve, reject) => { - timer = setTimeout(() => { - controller.abort() - reject( - new LinearWriteFailure( - 'unconfirmed', - 'Linear write deadline elapsed before confirmation.' - ) - ) - }, 25_000) - }) - ]) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - throw error - } - if (error instanceof LinearWriteFailure && error.kind === 'unconfirmed') { - throw unconfirmed(this.linearWriteFailureCauseMessage(error)) - } - if (error instanceof LinearWriteFailure && error.kind === 'network') { - throw linearError('linear_network_error', sanitizeLinearErrorMessage(error.message)) - } - if (error instanceof LinearWriteFailure) { - throw linearError('linear_write_failed', sanitizeLinearErrorMessage(error.message)) - } - throw this.mapLinearReadFailure(error) - } finally { - if (timer) { - clearTimeout(timer) - } - } + public runLinearAgentAction( + prepare: (signal: AbortSignal) => Promise, + write: ( + owner: Owner, + options: { signal: AbortSignal; beforeInvoke: () => void } + ) => Promise, + unconfirmed: (owner: Owner, cause?: string) => LinearAgentAccessError, + cancellation?: AbortSignal, + mapWriteFailure?: (error: LinearWriteFailure) => LinearAgentAccessError + ): Promise<{ owner: Owner; result: Result }> { + return runLinearWriteAction( + prepare, + write, + unconfirmed, + (error) => this.mapLinearReadFailure(error), + cancellation, + mapWriteFailure + ) } - public linearWriteFailureCauseMessage(error: LinearWriteFailure): string { - if (error.cause instanceof Error) { - return sanitizeLinearErrorMessage(error.cause.message) - } - if (error.cause !== undefined) { - return sanitizeLinearErrorMessage(String(error.cause)) - } - return sanitizeLinearErrorMessage(error.message) + public mapLinearCommentWriteFailure(error: LinearWriteFailure): LinearAgentAccessError { + return linearError( + error.kind === 'network' ? 'linear_network_error' : 'linear_write_failed', + 'Linear could not complete the comment change. Retry after checking the comment.' + ) } public mapLinearReadFailure(error: unknown): LinearAgentAccessError { diff --git a/src/main/runtime/runtime-linear-create-commands.ts b/src/main/runtime/runtime-linear-create-commands.ts index 379bd16301d..fc5d974eb54 100644 --- a/src/main/runtime/runtime-linear-create-commands.ts +++ b/src/main/runtime/runtime-linear-create-commands.ts @@ -12,72 +12,83 @@ import type { import { RuntimeLinearSaveFieldCommands } from './runtime-linear-save-fields-commands' export class RuntimeLinearCreateCommands extends RuntimeLinearSaveFieldCommands { - async linearIssueCreate(params: { - title: string - body?: string - teamInput?: string - teamKey?: string - state?: string - assignee?: string - priority?: number - estimate?: number - dueDate?: string - labels?: string[] - projectInput?: string - parentInput?: string - parentCurrent?: boolean - workspaceId?: string - writeId?: string - context?: LinearCurrentIssueContextHints - }): Promise { + async linearIssueCreate( + params: { + title: string + body?: string + teamInput?: string + teamKey?: string + state?: string + assignee?: string + priority?: number + estimate?: number + dueDate?: string + labels?: string[] + projectInput?: string + parentInput?: string + parentCurrent?: boolean + workspaceId?: string + writeId?: string + context?: LinearCurrentIssueContextHints + }, + cancellation?: AbortSignal + ): Promise { if ((params.body?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { throw linearError('linear_body_too_large', 'Linear issue body is too large.') } - const parent = - params.parentInput || params.parentCurrent - ? await this.resolveLinearAgentWriteTarget({ - input: params.parentInput, - current: params.parentCurrent, - workspaceId: params.workspaceId, - context: params.context - }) - : null - if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) { - throw linearError( - 'linear_invalid_workspace', - 'The parent issue belongs to a different workspace.' - ) - } - const team = await this.resolveLinearCreateTeam( - params.teamInput ?? params.teamKey, - params.workspaceId, - parent - ) - const createFields = await this.resolveLinearCreateFields(params, team) - const parentId = parent?.issue.id ?? null const writeId = params.writeId ?? randomUUID() - const existing = - params.writeId !== undefined - ? await this.getMatchingLinearCreatedIssue( - writeId, - team.id, - parentId, - team.workspaceId, - true, - createFields + const { + owner: { team, parent }, + result + } = await this.runLinearAgentAction( + async (signal) => { + const parent = + params.parentInput || params.parentCurrent + ? await this.resolveLinearAgentWriteTarget( + { + input: params.parentInput, + current: params.parentCurrent, + workspaceId: params.workspaceId, + context: params.context + }, + signal + ) + : null + if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) { + throw linearError( + 'linear_invalid_workspace', + 'The parent issue belongs to a different workspace.' ) - : null - if (existing) { - if (parent) { - await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) - } - return this.linearCreateResult(existing, team.workspaceId, writeId, true) - } + } + const team = await this.resolveLinearCreateTeam( + params.teamInput ?? params.teamKey, + params.workspaceId, + parent, + signal + ) + const createFields = await this.resolveLinearCreateFields(params, team, signal) + const parentId = parent?.issue.id ?? null + const existing = + params.writeId !== undefined + ? await this.getMatchingLinearCreatedIssue( + writeId, + team.id, + parentId, + team.workspaceId, + true, + createFields, + signal + ) + : null + return { team, parent, parentId, createFields, existing } + }, + async ({ team, parent, parentId, createFields, existing }, options) => { + if (existing) { + return { issue: existing, deduplicated: true } + } - try { - const issue = await this.runLinearAgentWrite( - async (signal) => { - const created = await createLinearIssueForAgent( + try { + const issue = await createLinearIssueForAgent( team.id, params.title, params.body, @@ -86,54 +97,53 @@ export class RuntimeLinearCreateCommands extends RuntimeLinearSaveFieldCommands id: writeId, parentId, ...createFields, - signal + ...options } ) - if (!this.linearCreatedIssueMatchesIntent(created, createFields)) { + if (!this.linearCreatedIssueMatchesIntent(issue, createFields)) { throw new LinearWriteFailure( 'unconfirmed', 'Linear issue create could not be confirmed with the requested task fields.' ) } - return created - }, - (cause) => - this.linearCreateStyleUnconfirmed('create', writeId, null, { - team, - parent, - title: params.title, - bodyRequired: params.body !== undefined, - createFields, - cause - }) - ) - if (parent) { - await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) - } - return this.linearCreateResult(issue, team.workspaceId, writeId, false) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - const issue = await this.refetchLinearIssueAfterDuplicate( - writeId, - team.id, - parentId, - team.workspaceId, - createFields, - () => - this.linearCreateStyleUnconfirmed('create', writeId, null, { - team, - parent, - title: params.title, - bodyRequired: params.body !== undefined, - createFields - }) - ) - if (parent) { - await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) + return { issue, deduplicated: false } + } catch (error) { + if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { + const issue = await this.refetchLinearIssueAfterDuplicate( + writeId, + team.id, + parentId, + team.workspaceId, + createFields, + () => + this.linearCreateStyleUnconfirmed('create', writeId, null, { + team, + parent, + title: params.title, + bodyRequired: params.body !== undefined, + createFields + }), + options.signal + ) + return { issue, deduplicated: true } + } + throw error } - return this.linearCreateResult(issue, team.workspaceId, writeId, true) - } - throw error + }, + ({ team, parent, createFields }, cause) => + this.linearCreateStyleUnconfirmed('create', writeId, null, { + team, + parent, + title: params.title, + bodyRequired: params.body !== undefined, + createFields, + cause + }), + cancellation + ) + if (parent) { + this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) } + return this.linearCreateResult(result.issue, team.workspaceId, writeId, result.deduplicated) } } diff --git a/src/main/runtime/runtime-linear-dedupe-commands.ts b/src/main/runtime/runtime-linear-dedupe-commands.ts index 3c7f911decb..111446e6ea6 100644 --- a/src/main/runtime/runtime-linear-dedupe-commands.ts +++ b/src/main/runtime/runtime-linear-dedupe-commands.ts @@ -18,10 +18,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma issueId: string, parentId: string | null, workspaceId: string, - required: boolean + required: boolean, + signal?: AbortSignal ): Promise> | null> { + signal?.throwIfAborted() const comment = await this.readLinearWriteLookup(() => - getLinearCommentByUuidForAgent(writeId, workspaceId) + getLinearCommentByUuidForAgent(writeId, workspaceId, signal) ) if (!comment) { return null @@ -42,10 +44,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma writeId: string, issueId: string, workspaceId: string, - required: boolean + required: boolean, + signal?: AbortSignal ): Promise> | null> { + signal?.throwIfAborted() const attachment = await this.readLinearWriteLookup(() => - getLinearAttachmentByUuidForAgent(writeId, workspaceId) + getLinearAttachmentByUuidForAgent(writeId, workspaceId, signal) ) if (!attachment) { return null @@ -68,10 +72,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma parentId: string | null, workspaceId: string, required: boolean, - intent: LinearCreateFieldIntent = {} + intent: LinearCreateFieldIntent = {}, + signal?: AbortSignal ): Promise> | null> { + signal?.throwIfAborted() const issue = await this.readLinearWriteLookup(() => - getLinearIssueByUuidForAgent(writeId, workspaceId) + getLinearIssueByUuidForAgent(writeId, workspaceId, signal) ) if (!issue) { return null @@ -97,8 +103,10 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma issueId: string, parentId: string | null, workspaceId: string, - unconfirmed: (cause?: string) => LinearAgentAccessError + unconfirmed: (cause?: string) => LinearAgentAccessError, + signal?: AbortSignal ): Promise>>> { + signal?.throwIfAborted() try { // Duplicate id may mean the original write landed; only the exact target relationship proves this retry. const comment = await this.getMatchingLinearCommentWrite( @@ -106,7 +114,8 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma issueId, parentId, workspaceId, - true + true, + signal ) if (comment) { return comment @@ -128,15 +137,18 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma writeId: string, issueId: string, workspaceId: string, - unconfirmed: (cause?: string) => LinearAgentAccessError + unconfirmed: (cause?: string) => LinearAgentAccessError, + signal?: AbortSignal ): Promise>>> { + signal?.throwIfAborted() try { // Duplicate id may mean the original write landed; only the exact target relationship proves this retry. const attachment = await this.getMatchingLinearAttachmentWrite( writeId, issueId, workspaceId, - true + true, + signal ) if (attachment) { return attachment @@ -160,8 +172,10 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma parentId: string | null, workspaceId: string, intent: LinearCreateFieldIntent, - unconfirmed: (cause?: string) => LinearAgentAccessError + unconfirmed: (cause?: string) => LinearAgentAccessError, + signal?: AbortSignal ): Promise>>> { + signal?.throwIfAborted() try { // Duplicate id may mean the original write landed; only the exact target relationship proves this retry. const issue = await this.getMatchingLinearCreatedIssue( @@ -170,7 +184,8 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma parentId, workspaceId, true, - intent + intent, + signal ) if (issue) { return issue diff --git a/src/main/runtime/runtime-linear-label-write-commands.ts b/src/main/runtime/runtime-linear-label-write-commands.ts index 69934f9ee32..5fc8ec94dac 100644 --- a/src/main/runtime/runtime-linear-label-write-commands.ts +++ b/src/main/runtime/runtime-linear-label-write-commands.ts @@ -13,9 +13,11 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo public async resolveLinearLabelsForIssue( issue: NonNullable>>, inputs: string[], - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise<{ id: string; name: string }[]> { - const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId) + signal?.throwIfAborted() + const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId, signal) const resolved = inputs.map((input) => { const normalized = input.toLowerCase() const idMatch = labels.find((label) => label.id.toLowerCase() === normalized) @@ -43,9 +45,11 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo public async resolveLinearLabelsForTeam( teamId: string, inputs: string[], - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise<{ id: string; name: string }[]> { - const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId) + signal?.throwIfAborted() + const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId, signal) const resolved = inputs.map((input) => { const normalized = input.toLowerCase() const idMatch = labels.find((label) => label.id.toLowerCase() === normalized) @@ -129,10 +133,12 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo } public async getLinearTeamLabelsForWrite( teamId: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise>> { + signal?.throwIfAborted() try { - return await getLinearTeamLabelsOrThrow(teamId, workspaceId) + return await getLinearTeamLabelsOrThrow(teamId, workspaceId, signal) } catch (error) { throw this.mapLinearReadFailure(error) } diff --git a/src/main/runtime/runtime-linear-project-write-commands.ts b/src/main/runtime/runtime-linear-project-write-commands.ts index 8f7892e550c..8896b29ebc0 100644 --- a/src/main/runtime/runtime-linear-project-write-commands.ts +++ b/src/main/runtime/runtime-linear-project-write-commands.ts @@ -14,38 +14,61 @@ import { RuntimeLinearTeamWriteCommands } from './runtime-linear-team-write-comm export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCommands { public async resolveLinearCreateProject( input: string, - team: { id: string; workspaceId: string } + team: { id: string; workspaceId: string }, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const trimmed = input.trim() if (!trimmed) { throw linearError('linear_invalid_project', 'Pass a non-empty Linear project id or name.') } const byId = isLinearUuid(trimmed) - ? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId) + ? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId, signal) : null if (byId) { - await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed) + await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed, signal) return byId } - const searchCandidates = await this.readLinearProjectsForCreate(trimmed, team.workspaceId) + const searchCandidates = await this.readLinearProjectsForCreate( + trimmed, + team.workspaceId, + signal + ) const normalized = trimmed.toLowerCase() const idMatch = searchCandidates.find((project) => project.id.toLowerCase() === normalized) if (idMatch) { - await this.assertLinearProjectIncludesTeam(idMatch, team.id, team.workspaceId, trimmed) + await this.assertLinearProjectIncludesTeam( + idMatch, + team.id, + team.workspaceId, + trimmed, + signal + ) return idMatch } const slugMatch = searchCandidates.find( (project) => project.slugId?.toLowerCase() === normalized ) if (slugMatch) { - await this.assertLinearProjectIncludesTeam(slugMatch, team.id, team.workspaceId, trimmed) + await this.assertLinearProjectIncludesTeam( + slugMatch, + team.id, + team.workspaceId, + trimmed, + signal + ) return slugMatch } - const nameMatches = await this.readLinearProjectsByExactNameForCreate(trimmed, team.workspaceId) + const nameMatches = await this.readLinearProjectsByExactNameForCreate( + trimmed, + team.workspaceId, + signal + ) const compatibleNameMatches = await this.filterLinearProjectsForTeam( nameMatches, team.id, - team.workspaceId + team.workspaceId, + signal ) if (compatibleNameMatches.length === 1) { return compatibleNameMatches[0] @@ -65,7 +88,13 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom ) } if (nameMatches.length > 0) { - await this.assertLinearProjectIncludesTeam(nameMatches[0], team.id, team.workspaceId, trimmed) + await this.assertLinearProjectIncludesTeam( + nameMatches[0], + team.id, + team.workspaceId, + trimmed, + signal + ) } throw linearError('linear_invalid_project', `No Linear project exactly matched "${trimmed}".`, { projects: searchCandidates.map((project) => ({ @@ -79,10 +108,12 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom public async readLinearProjectByIdForCreate( id: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() try { - return await getLinearProject(id, workspaceId, true) + return await getLinearProject(id, workspaceId, true, signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -90,10 +121,13 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom public async readLinearProjectsForCreate( query: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() try { - return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true)).items + return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true, signal)) + .items } catch (error) { throw this.mapLinearReadFailure(error) } @@ -101,10 +135,12 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom public async readLinearProjectsByExactNameForCreate( name: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() try { - return await listLinearProjectsByExactName(name, workspaceId, true) + return await listLinearProjectsByExactName(name, workspaceId, true, signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -114,15 +150,17 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom project: LinearProjectSummary, teamId: string, workspaceId: string, - input: string + input: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() if (this.linearProjectIncludesTeam(project, teamId)) { return } let teams: NonNullable = [] try { // Summary team lists are capped; page membership before refusing cross-team creates. - teams = await listLinearProjectTeams(project.id, workspaceId, true) + teams = await listLinearProjectTeams(project.id, workspaceId, true, signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -142,8 +180,10 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom public async filterLinearProjectsForTeam( projects: LinearProjectSummary[], teamId: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const compatible: LinearProjectSummary[] = [] for (const project of projects) { if (this.linearProjectIncludesTeam(project, teamId)) { @@ -151,7 +191,7 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom continue } try { - const teams = await listLinearProjectTeams(project.id, workspaceId, true) + const teams = await listLinearProjectTeams(project.id, workspaceId, true, signal) if (teams.some((team) => team.id === teamId)) { compatible.push({ ...project, teams }) } @@ -167,10 +207,12 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom } public async getLinearViewerForWrite( - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> { + signal?.throwIfAborted() try { - return await getLinearViewerForWorkspaceOrThrow(workspaceId) + return await getLinearViewerForWorkspaceOrThrow(workspaceId, signal) } catch (error) { throw this.mapLinearReadFailure(error) } diff --git a/src/main/runtime/runtime-linear-retry-commands.ts b/src/main/runtime/runtime-linear-retry-commands.ts index eed81cf8df7..8c7e7928afc 100644 --- a/src/main/runtime/runtime-linear-retry-commands.ts +++ b/src/main/runtime/runtime-linear-retry-commands.ts @@ -84,7 +84,16 @@ export class RuntimeLinearRetryCommands extends RuntimeLinearCommandBase { return /^[A-Za-z0-9._:@%+=,/-]+$/.test(value) ? value : placeholder } - public async notifyLinearLinkedIssueUpdated( + public notifyLinearLinkedIssueUpdated( + workspaceId: string, + identifier: string | readonly string[] + ): void { + void this.refreshLinearLinkedIssues(workspaceId, identifier).catch((error: unknown) => { + console.warn('[linear] Linked issue refresh failed:', error) + }) + } + + private async refreshLinearLinkedIssues( workspaceId: string, identifier: string | readonly string[] ): Promise { diff --git a/src/main/runtime/runtime-linear-save-commands.ts b/src/main/runtime/runtime-linear-save-commands.ts index 3bb15f07065..2fdca94b627 100644 --- a/src/main/runtime/runtime-linear-save-commands.ts +++ b/src/main/runtime/runtime-linear-save-commands.ts @@ -13,7 +13,10 @@ import type { import { RuntimeLinearCommentCommands } from './runtime-linear-comment-commands' export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands { - async linearSaveIssue(params: LinearSaveIssueRequest): Promise { + async linearSaveIssue( + params: LinearSaveIssueRequest, + cancellation?: AbortSignal + ): Promise { if ((params.description?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { throw linearError('linear_body_too_large', 'Linear issue body is too large.') } @@ -24,65 +27,77 @@ export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands { 'Creating with save-issue requires both team and title.' ) } - const created = await this.linearIssueCreate({ - title: params.title, - body: params.description, - teamInput: params.team, - state: params.state, - assignee: params.assignee ?? undefined, - priority: params.priority, - estimate: params.estimate ?? undefined, - dueDate: params.dueDate ?? undefined, - labels: params.labels, - projectInput: params.project ?? undefined, - parentInput: params.parentId ?? undefined, - workspaceId: params.workspaceId, - writeId: params.writeId, - context: params.context - }) + const created = await this.linearIssueCreate( + { + title: params.title, + body: params.description, + teamInput: params.team, + state: params.state, + assignee: params.assignee ?? undefined, + priority: params.priority, + estimate: params.estimate ?? undefined, + dueDate: params.dueDate ?? undefined, + labels: params.labels, + projectInput: params.project ?? undefined, + parentInput: params.parentId ?? undefined, + workspaceId: params.workspaceId, + writeId: params.writeId, + context: params.context + }, + cancellation + ) return { ...created, meta: { ...created.meta, created: true } } } if (params.team !== undefined) { throw linearError('linear_write_failed', 'Team can only be set when creating an issue.') } - const target = await this.resolveLinearAgentWriteTarget(params) - const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) - const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId) - if (Object.keys(fields).length === 0) { - throw linearError('linear_write_failed', 'No issue fields were provided to save.') - } - const alreadySet = await this.linearSavedIssueMatchesIntent(current, fields) - const updated = alreadySet - ? current - : await this.runLinearAgentWrite( - async (signal) => { - const saved = await updateLinearIssueForAgent( - target.issue.id, - fields, - target.workspaceId, - { signal } - ) - if (!(await this.linearSavedIssueMatchesIntent(saved, fields))) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear issue save could not be confirmed.' - ) - } - return saved - }, - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the issue save, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) + const { + owner: { target }, + result: updated + } = await this.runLinearAgentAction( + async (signal) => { + const target = await this.resolveLinearAgentWriteTarget(params, signal) + const current = await this.readLinearAgentIssueWriteRecord( + target.issue.id, + target.workspaceId, + signal ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId, signal) + if (Object.keys(fields).length === 0) { + throw linearError('linear_write_failed', 'No issue fields were provided to save.') + } + const alreadySet = await this.linearSavedIssueMatchesIntent(current, fields) + return { target, current, fields, alreadySet } + }, + async ({ target, current, fields, alreadySet }, options) => { + if (alreadySet) { + return current + } + const saved = await updateLinearIssueForAgent( + target.issue.id, + fields, + target.workspaceId, + options + ) + if (!(await this.linearSavedIssueMatchesIntent(saved, fields))) { + throw new LinearWriteFailure('unconfirmed', 'Linear issue save could not be confirmed.') + } + return saved + }, + ({ target }, cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the issue save, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.` + ], + ...(cause ? { cause } : {}) + } + ), + cancellation + ) + void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return { issue: updated, meta: { @@ -93,49 +108,59 @@ export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands { } async linearIssueUpdateTask( - params: LinearIssueTaskUpdateRequest + params: LinearIssueTaskUpdateRequest, + cancellation?: AbortSignal ): Promise { - const target = await this.resolveLinearAgentWriteTarget(params) - const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) - const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId) - if (!update) { - throw linearError('linear_write_failed', 'No Linear task field update was requested.') - } - const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update) - if (!alreadySet) { - await this.runLinearAgentWrite( - async (signal) => { - const updated = await updateLinearIssueForAgent( - target.issue.id, - update.fields, - target.workspaceId, - { signal } + const { + owner: { target, current, alreadySet }, + result: finalRecord + } = await this.runLinearAgentAction( + async (signal) => { + const target = await this.resolveLinearAgentWriteTarget(params, signal) + const current = await this.readLinearAgentIssueWriteRecord( + target.issue.id, + target.workspaceId, + signal + ) + const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId, signal) + if (!update) { + throw linearError('linear_write_failed', 'No Linear task field update was requested.') + } + const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update) + return { target, current, update, alreadySet } + }, + async ({ target, current, update, alreadySet }, options) => { + if (alreadySet) { + return current + } + const updated = await updateLinearIssueForAgent( + target.issue.id, + update.fields, + target.workspaceId, + options + ) + if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) { + throw new LinearWriteFailure( + 'unconfirmed', + 'Linear task field update could not be confirmed.' ) - if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear task field update could not be confirmed.' - ) + } + return updated + }, + ({ target }, cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the task update, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.` + ], + ...(cause ? { cause } : {}) } - return updated - }, - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the task update, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - } - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - const finalRecord = alreadySet - ? current - : await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) + ), + cancellation + ) + void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearTaskUpdateResult( params.operation, target.issue, diff --git a/src/main/runtime/runtime-linear-save-fields-commands.ts b/src/main/runtime/runtime-linear-save-fields-commands.ts index 6676702788e..866e667d227 100644 --- a/src/main/runtime/runtime-linear-save-fields-commands.ts +++ b/src/main/runtime/runtime-linear-save-fields-commands.ts @@ -70,11 +70,13 @@ export class RuntimeLinearSaveFieldCommands extends RuntimeLinearTaskFieldComman labels?: string[] projectInput?: string }, - team: { id: string; workspaceId: string } + team: { id: string; workspaceId: string }, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const fields: LinearCreateFieldIntent = {} if (params.state) { - const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) + const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId, signal) const state = this.resolveLinearAgentState(params.state, states) if (!state) { throw linearError( @@ -89,7 +91,8 @@ export class RuntimeLinearSaveFieldCommands extends RuntimeLinearTaskFieldComman fields.assigneeId = await this.resolveLinearAssignee( params.assignee, team.id, - team.workspaceId + team.workspaceId, + signal ) } if (params.priority !== undefined) { @@ -102,11 +105,16 @@ export class RuntimeLinearSaveFieldCommands extends RuntimeLinearTaskFieldComman fields.dueDate = params.dueDate } if (params.labels && params.labels.length > 0) { - const labels = await this.resolveLinearLabelsForTeam(team.id, params.labels, team.workspaceId) + const labels = await this.resolveLinearLabelsForTeam( + team.id, + params.labels, + team.workspaceId, + signal + ) fields.labelIds = labels.map((label) => label.id) } if (params.projectInput) { - const project = await this.resolveLinearCreateProject(params.projectInput, team) + const project = await this.resolveLinearCreateProject(params.projectInput, team, signal) fields.projectId = project.id } return fields diff --git a/src/main/runtime/runtime-linear-state-commands.ts b/src/main/runtime/runtime-linear-state-commands.ts index e1890880aef..f35a3961133 100644 --- a/src/main/runtime/runtime-linear-state-commands.ts +++ b/src/main/runtime/runtime-linear-state-commands.ts @@ -13,69 +13,76 @@ import type { import { RuntimeLinearSaveCommands } from './runtime-linear-save-commands' export class RuntimeLinearStateCommands extends RuntimeLinearSaveCommands { - async linearIssueSetState(params: { - input?: string - current?: boolean - workspaceId?: string - to: string - context?: LinearCurrentIssueContextHints - }): Promise { - const target = await this.resolveLinearAgentWriteTarget(params) - const teamId = target.issue.team?.id - if (!teamId) { - throw linearError('linear_invalid_state', 'The Linear issue does not have a team.') - } - const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId) - const state = this.resolveLinearAgentState(params.to, states) - if (!state) { - throw linearError( - 'linear_invalid_state', - `No workflow state exactly matched "${params.to}".`, - { - states: states.map(({ id, name, type }) => ({ id, name, type })), - nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`] + async linearIssueSetState( + params: { + input?: string + current?: boolean + workspaceId?: string + to: string + context?: LinearCurrentIssueContextHints + }, + cancellation?: AbortSignal + ): Promise { + const { + owner: { target, state, previousState, alreadyInState } + } = await this.runLinearAgentAction( + async (signal) => { + const target = await this.resolveLinearAgentWriteTarget(params, signal) + const teamId = target.issue.team?.id + if (!teamId) { + throw linearError('linear_invalid_state', 'The Linear issue does not have a team.') + } + const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId, signal) + const state = this.resolveLinearAgentState(params.to, states) + if (!state) { + throw linearError( + 'linear_invalid_state', + `No workflow state exactly matched "${params.to}".`, + { + states: states.map(({ id, name, type }) => ({ id, name, type })), + nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`] + } + ) } - ) - } - const previousState = - target.issue.state?.id && target.issue.state.name - ? { id: target.issue.state.id, name: target.issue.state.name } - : null - const alreadyInState = target.issue.state?.id === state.id - if (!alreadyInState) { - await this.runLinearAgentWrite( - async (signal) => { - const updated = await updateLinearIssueForAgent( - target.issue.id, - { stateId: state.id }, - target.workspaceId, - { - signal - } - ) - if (updated.state?.id !== state.id) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear state update could not be confirmed.' - ) + const previousState = + target.issue.state?.id && target.issue.state.name + ? { id: target.issue.state.id, name: target.issue.state.name } + : null + const alreadyInState = target.issue.state?.id === state.id + return { target, state, previousState, alreadyInState } + }, + async ({ target, state, alreadyInState }, options) => { + if (alreadyInState) { + return + } + const updated = await updateLinearIssueForAgent( + target.issue.id, + { stateId: state.id }, + target.workspaceId, + { + ...options } - return updated - }, - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the state change, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - } - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) + ) + if (updated.state?.id !== state.id) { + throw new LinearWriteFailure('unconfirmed', 'Linear state update could not be confirmed.') + } + return updated + }, + ({ target }, cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the state change, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.` + ], + ...(cause ? { cause } : {}) + } + ), + cancellation + ) + void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return { issue: this.linearWriteIssueRef(target.issue), state: { id: state.id, name: state.name, type: state.type }, @@ -85,50 +92,57 @@ export class RuntimeLinearStateCommands extends RuntimeLinearSaveCommands { } async linearIssueRelationWrite( - params: LinearIssueRelationWriteRequest + params: LinearIssueRelationWriteRequest, + cancellation?: AbortSignal ): Promise { - const target = await this.resolveLinearAgentWriteTarget(params) - const related = await this.resolveLinearAgentWriteTarget({ - input: params.relatedInput, - workspaceId: target.workspaceId, - context: params.context - }) - if (target.issue.id === related.issue.id) { - throw linearError('linear_write_failed', 'An issue cannot be related to itself.') - } - try { - const result = await this.runLinearAgentWrite( - (signal) => - writeIssueRelation({ - issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title }, - relatedIssue: { - ...this.linearWriteIssueRef(related.issue), - title: related.issue.title - }, - relationship: params.relationship, - operation: params.operation, + const { + owner: { target, related }, + result + } = await this.runLinearAgentAction( + async (signal) => { + const target = await this.resolveLinearAgentWriteTarget(params, signal) + const related = await this.resolveLinearAgentWriteTarget( + { + input: params.relatedInput, workspaceId: target.workspaceId, - signal - }), - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the relation change, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, [ - target.issue.identifier, - related.issue.identifier - ]) - return result - } catch (error) { - throw this.mapLinearReadFailure(error) - } + context: params.context + }, + signal + ) + if (target.issue.id === related.issue.id) { + throw linearError('linear_write_failed', 'An issue cannot be related to itself.') + } + return { target, related } + }, + ({ target, related }, options) => + writeIssueRelation({ + issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title }, + relatedIssue: { + ...this.linearWriteIssueRef(related.issue), + title: related.issue.title + }, + relationship: params.relationship, + operation: params.operation, + workspaceId: target.workspaceId, + ...options + }), + ({ target }, cause) => + linearError( + 'linear_write_unconfirmed', + 'Linear may have applied the relation change, but Orca could not confirm it.', + { + nextSteps: [ + `Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.` + ], + ...(cause ? { cause } : {}) + } + ), + cancellation + ) + void this.notifyLinearLinkedIssueUpdated(target.workspaceId, [ + target.issue.identifier, + related.issue.identifier + ]) + return result } } diff --git a/src/main/runtime/runtime-linear-task-fields-commands.ts b/src/main/runtime/runtime-linear-task-fields-commands.ts index 59a81f8c9a3..3541c1fec92 100644 --- a/src/main/runtime/runtime-linear-task-fields-commands.ts +++ b/src/main/runtime/runtime-linear-task-fields-commands.ts @@ -19,10 +19,12 @@ import { RuntimeLinearLabelWriteCommands } from './runtime-linear-label-write-co export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteCommands { public async getLinearTeamStatesForWrite( teamId: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise>> { + signal?.throwIfAborted() try { - return await getLinearTeamStatesOrThrow(teamId, workspaceId) + return await getLinearTeamStatesOrThrow(teamId, workspaceId, signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -42,10 +44,12 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma public async readLinearAgentIssueWriteRecord( issueId: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise>>> { + signal?.throwIfAborted() const issue = await this.readLinearWriteLookup(() => - getLinearIssueByUuidForAgent(issueId, workspaceId) + getLinearIssueByUuidForAgent(issueId, workspaceId, signal) ) if (!issue) { throw linearError('linear_issue_not_found', 'Linear issue was not found.') @@ -56,7 +60,8 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma public async buildLinearTaskUpdate( params: LinearIssueTaskUpdateRequest, current: NonNullable>>, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise<{ fields: { assigneeId?: string | null @@ -67,9 +72,10 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma } labels?: { id: string; name: string }[] } | null> { + signal?.throwIfAborted() if (params.operation === 'assignee') { const assigneeId = params.assigneeMe - ? (await this.getLinearViewerForWrite(workspaceId)).id + ? (await this.getLinearViewerForWrite(workspaceId, signal)).id : params.assigneeId if (assigneeId === undefined) { throw linearError('linear_invalid_assignee', 'Pass --me, --to-id, or clear assignee.') @@ -100,7 +106,7 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma if (!mode || inputs.length === 0) { throw linearError('linear_invalid_label', 'Pass at least one --label.') } - const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId) + const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId, signal) const requestedIds = labels.map((label) => label.id) const existingIds = current.labelIds ?? current.labels?.map((label) => label.id) ?? [] const nextIds = @@ -120,8 +126,10 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma public async buildLinearSaveUpdate( params: LinearSaveIssueRequest, current: NonNullable>>, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const fields: LinearIssueUpdate = {} if (params.title !== undefined) { fields.title = params.title @@ -139,7 +147,7 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma fields.dueDate = params.dueDate } if (params.state !== undefined) { - const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId) + const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId, signal) const state = this.resolveLinearAgentState(params.state, states) if (!state) { throw linearError( @@ -153,13 +161,18 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma fields.assigneeId = params.assignee === null ? null - : await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId) + : await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId, signal) } if (params.labels !== undefined) { if (params.labels.length === 0) { fields.labelIds = [] } else { - const labels = await this.resolveLinearLabelsForIssue(current, params.labels, workspaceId) + const labels = await this.resolveLinearLabelsForIssue( + current, + params.labels, + workspaceId, + signal + ) fields.labelIds = labels.map((label) => label.id) } } @@ -168,10 +181,14 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma params.project === null ? null : ( - await this.resolveLinearCreateProject(params.project, { - id: current.team.id, - workspaceId - }) + await this.resolveLinearCreateProject( + params.project, + { + id: current.team.id, + workspaceId + }, + signal + ) ).id } if (params.parentId !== undefined) { @@ -179,11 +196,14 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma params.parentId === null ? null : ( - await this.resolveLinearAgentWriteTarget({ - input: params.parentId, - workspaceId, - context: params.context - }) + await this.resolveLinearAgentWriteTarget( + { + input: params.parentId, + workspaceId, + context: params.context + }, + signal + ) ).issue.id if (fields.parentId === current.id) { throw linearError('linear_invalid_parent', 'An issue cannot be its own parent.') @@ -194,10 +214,12 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma public async resolveLinearAssignee( input: string, teamId: string, - workspaceId: string + workspaceId: string, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() if (input.toLowerCase() === 'me') { - return (await this.getLinearViewerForWrite(workspaceId)).id + return (await this.getLinearViewerForWrite(workspaceId, signal)).id } // Keep the existing direct-id fast path; avoid a paginated member scan. if (isLinearUuid(input)) { @@ -205,7 +227,7 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma } let members: Awaited> try { - members = await getLinearTeamMembersOrThrow(teamId, workspaceId) + members = await getLinearTeamMembersOrThrow(teamId, workspaceId, signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -227,12 +249,16 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma : `Multiple team members exactly matched "${input}".` ) } - public async resolveLinearAgentWriteTarget(params: { - input?: string - current?: boolean - workspaceId?: string - context?: LinearCurrentIssueContextHints - }): Promise { + public async resolveLinearAgentWriteTarget( + params: { + input?: string + current?: boolean + workspaceId?: string + context?: LinearCurrentIssueContextHints + }, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() const result = await readLinearIssueContext( { input: params.input, @@ -248,7 +274,8 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma depth: 0, context: params.context }, - (context) => this.linearResolveCurrentIssue(context) + (context) => this.linearResolveCurrentIssue(context, signal), + signal ) return { issue: result.issue, workspaceId: result.meta.resolved.workspaceId } } diff --git a/src/main/runtime/runtime-linear-team-write-commands.ts b/src/main/runtime/runtime-linear-team-write-commands.ts index d921b8c5a80..8fe3bf891d1 100644 --- a/src/main/runtime/runtime-linear-team-write-commands.ts +++ b/src/main/runtime/runtime-linear-team-write-commands.ts @@ -77,7 +77,8 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands public async resolveLinearTeamInput( teamInput: string, - workspaceId?: (string & {}) | 'all' + workspaceId?: (string & {}) | 'all', + signal?: AbortSignal ): Promise<{ id: string key: string @@ -85,10 +86,11 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands workspaceId: string workspaceName?: string }> { + signal?.throwIfAborted() this.validateLinearCreateWorkspaceScope(workspaceId === 'all' ? undefined : workspaceId) let teams: Awaited> try { - teams = await listLinearTeamsOrThrow(workspaceId ?? 'all') + teams = await listLinearTeamsOrThrow(workspaceId ?? 'all', signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -127,8 +129,10 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands public async resolveLinearCreateTeam( teamInput: string | undefined, workspaceId: string | undefined, - parent: LinearAgentWriteTarget | null + parent: LinearAgentWriteTarget | null, + signal?: AbortSignal ): Promise<{ id: string; key: string; name: string; workspaceId: string }> { + signal?.throwIfAborted() if (!teamInput && parent?.issue.team?.id && parent.issue.team.key && parent.issue.team.name) { return { id: parent.issue.team.id, @@ -147,7 +151,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands this.validateLinearCreateWorkspaceScope(scope) let teams: Awaited> try { - teams = await listLinearTeamsOrThrow(scope ?? 'all') + teams = await listLinearTeamsOrThrow(scope ?? 'all', signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -185,7 +189,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands if (parent) { let globalTeams: Awaited> try { - globalTeams = await listLinearTeamsOrThrow('all') + globalTeams = await listLinearTeamsOrThrow('all', signal) } catch (error) { throw this.mapLinearReadFailure(error) } @@ -217,7 +221,8 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands } } async linearResolveCurrentIssue( - context?: LinearCurrentIssueContextHints + context?: LinearCurrentIssueContextHints, + signal?: AbortSignal ): Promise> { if (!this.runtimeAvailable()) { throw new Error('runtime_unavailable') @@ -264,6 +269,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands ) } + signal?.throwIfAborted() const link = getLinearCurrentIssueFromWorktree(worktree) if (!link.workspaceId) { const backfill = resolveLegacyLinearLinkWorkspace( diff --git a/src/main/runtime/runtime-linear-comment-action.ts b/src/main/runtime/runtime-linear-write-action.ts similarity index 74% rename from src/main/runtime/runtime-linear-comment-action.ts rename to src/main/runtime/runtime-linear-write-action.ts index 3367a5c4b4c..65baa472c76 100644 --- a/src/main/runtime/runtime-linear-comment-action.ts +++ b/src/main/runtime/runtime-linear-write-action.ts @@ -1,25 +1,33 @@ import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { linearError, type LinearAgentAccessError } from '../linear/issue-context/errors' +import { + linearError, + sanitizeLinearErrorMessage, + type LinearAgentAccessError +} from '../linear/issue-context/errors' import { LinearWriteFailure } from '../linear/linear-issue-write-support' -export async function runLinearCommentAction( +export async function runLinearWriteAction( resolveOwner: (signal: AbortSignal) => Promise, write: ( owner: Owner, options: { signal: AbortSignal; beforeInvoke: () => void } ) => Promise, - unconfirmed: (owner: Owner) => LinearAgentAccessError, + unconfirmed: (owner: Owner, cause?: string) => LinearAgentAccessError, mapFailure: (error: unknown) => LinearAgentAccessError, - cancellation?: AbortSignal + cancellation?: AbortSignal, + mapWriteFailure?: (error: LinearWriteFailure) => LinearAgentAccessError ): Promise<{ owner: Owner; result: Result }> { const deadline = Date.now() + 55_000 const discoveryDeadline = Date.now() + 30_000 const action = new AbortController() const signal = cancellation ? AbortSignal.any([action.signal, cancellation]) : action.signal + let invoked = false const timeout = (): LinearAgentAccessError => linearError( 'linear_timeout', - 'The Linear comment action stopped before a change was attempted.' + invoked + ? 'Linear write deadline elapsed before confirmation.' + : 'The Linear action stopped before a change was attempted.' ) const totalTimer = setTimeout(() => action.abort(timeout()), 55_000) const discovery = new AbortController() @@ -30,7 +38,6 @@ export async function runLinearCommentAction( }, 30_000) let writeTimer: ReturnType | undefined let owner: Owner | undefined - let invoked = false try { const discoverySignal = AbortSignal.any([signal, discovery.signal]) try { @@ -63,15 +70,25 @@ export async function runLinearCommentAction( owner !== undefined && (signal.aborted || (error instanceof LinearWriteFailure && error.kind === 'unconfirmed')) ) { - throw unconfirmed(owner) + const cause = error instanceof LinearWriteFailure ? (error.cause ?? error) : error + throw unconfirmed( + owner, + sanitizeLinearErrorMessage(cause instanceof Error ? cause.message : String(cause)) + ) } if (signal.aborted || discoveryExpired) { throw timeout() } if (error instanceof LinearWriteFailure) { + if (mapWriteFailure) { + throw mapWriteFailure(error) + } + if (error.kind === 'duplicate_id') { + throw error + } throw linearError( error.kind === 'network' ? 'linear_network_error' : 'linear_write_failed', - 'Linear could not complete the comment change. Retry after checking the comment.' + sanitizeLinearErrorMessage(error.message) ) } throw mapFailure(error) diff --git a/src/main/runtime/runtime-linear-write-lifetime.test.ts b/src/main/runtime/runtime-linear-write-lifetime.test.ts new file mode 100644 index 00000000000..78b9a0e16b3 --- /dev/null +++ b/src/main/runtime/runtime-linear-write-lifetime.test.ts @@ -0,0 +1,472 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as mutations from '../linear/linear-issue-mutations' +import * as comments from '../linear/linear-issue-comments' +import * as relations from '../linear/issue-relation-write' +import { LinearWriteFailure } from '../linear/linear-issue-write-support' +import { RuntimeLinearReadCommands } from './runtime-linear-read-commands' +import type { LinearIssueTaskUpdateRequest } from './runtime-linear-command-dependencies' + +const issue = { + id: 'issue-1', + identifier: 'ENG-1', + title: 'Title', + description: '', + url: 'https://linear.app/acme/issue/ENG-1', + team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, + state: { id: 'todo', name: 'Todo' }, + parent: null, + project: null, + assignee: null, + priority: 0, + estimate: null, + dueDate: null, + labelIds: [], + labels: [] +} +const target = { issue, workspaceId: 'w1' } +const team = { ...issue.team, workspaceId: 'w1' } + +function commands(listResolvedWorktrees: () => Promise<[]> = async () => []) { + const unusedSelector = async () => { + throw new Error('unused') + } + const ports = { + runtimeAvailable: () => true, + showTerminal: async () => { + throw new Error('unused') + }, + resolveWorktreeSelector: unusedSelector, + resolveWorkspaceSelector: unusedSelector, + listResolvedWorktrees, + setWorktreeMeta: () => {}, + emitClientEvent: () => {} + } + const result = new RuntimeLinearReadCommands(ports) + vi.spyOn(result, 'resolveLinearAgentWriteTarget').mockResolvedValue(target) + vi.spyOn(result, 'resolveLinearCreateTeam').mockResolvedValue(team) + vi.spyOn(result, 'getLinearTeamStatesForWrite').mockResolvedValue([ + { id: 'done', name: 'Done', type: 'completed', color: '#888888', position: 1 } + ]) + vi.spyOn(result, 'readLinearAgentIssueWriteRecord').mockResolvedValue(issue) + vi.spyOn(result, 'getLinearViewerForWrite').mockResolvedValue({ id: 'user-1' }) + vi.spyOn(result, 'getLinearTeamLabelsForWrite').mockResolvedValue([ + { id: 'label-1', name: 'Bug', color: '#888888' } + ]) + return result +} + +type Commands = ReturnType +const taskUpdates: LinearIssueTaskUpdateRequest[] = [ + { operation: 'priority', priority: 2 }, + { operation: 'assignee', assigneeMe: true }, + { operation: 'labels', labelMode: 'add', labels: ['Bug'] }, + { operation: 'estimate', estimate: 3 }, + { operation: 'dueDate', dueDate: '2026-11-01' } +] +const actions: { name: string; run: (c: Commands, signal: AbortSignal) => Promise }[] = [ + { + name: 'status', + run: (c, signal) => c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }, signal) + }, + ...taskUpdates.map((update) => ({ + name: update.operation, + run: (c: Commands, signal: AbortSignal) => + c.linearIssueUpdateTask({ ...update, input: 'ENG-1' }, signal) + })), + { + name: 'save update', + run: (c, signal) => c.linearSaveIssue({ input: 'ENG-1', title: 'New' }, signal) + }, + { + name: 'save create', + run: (c, signal) => c.linearSaveIssue({ team: 'ENG', title: 'New' }, signal) + }, + { + name: 'create', + run: (c, signal) => c.linearIssueCreate({ teamInput: 'ENG', title: 'New' }, signal) + }, + { + name: 'comment', + run: (c, signal) => c.linearIssueAddComment({ input: 'ENG-1', body: 'body' }, signal) + }, + { + name: 'attach', + run: (c, signal) => + c.linearIssueAttachLink({ input: 'ENG-1', url: 'https://example.com' }, signal) + }, + ...(['add', 'remove'] as const).map((operation) => ({ + name: `relation ${operation}`, + run: (c: Commands, signal: AbortSignal) => + c.linearIssueRelationWrite( + { input: 'ENG-1', relatedInput: 'ENG-2', relationship: 'blocks', operation }, + signal + ) + })) +] + +beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(mutations, 'updateIssueForAgent').mockRejectedValue(new Error('unexpected mutation')) + vi.spyOn(mutations, 'createIssueForAgent').mockRejectedValue(new Error('unexpected mutation')) + vi.spyOn(comments, 'addIssueCommentForAgent').mockRejectedValue(new Error('unexpected mutation')) + vi.spyOn(comments, 'createIssueAttachment').mockRejectedValue(new Error('unexpected mutation')) + vi.spyOn(relations, 'writeIssueRelation').mockRejectedValue(new Error('unexpected mutation')) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it.each(actions)('bounds $name preparation and never invokes after it expires', async ({ run }) => { + const c = commands() + let release = () => {} + const held = new Promise((resolve) => { + release = () => resolve(target) + }) + vi.mocked(c.resolveLinearAgentWriteTarget).mockReturnValue(held) + vi.mocked(c.resolveLinearCreateTeam).mockImplementation(async () => { + await held + return team + }) + let outcome: unknown + const pending = run(c, new AbortController().signal).catch((error) => { + outcome = error + }) + try { + await vi.advanceTimersByTimeAsync(30_000) + expect(outcome).toMatchObject({ + code: 'linear_timeout', + message: 'The Linear action stopped before a change was attempted.' + }) + release() + await pending + await vi.advanceTimersByTimeAsync(0) + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + expect(mutations.createIssueForAgent).not.toHaveBeenCalled() + expect(comments.addIssueCommentForAgent).not.toHaveBeenCalled() + expect(comments.createIssueAttachment).not.toHaveBeenCalled() + expect(relations.writeIssueRelation).not.toHaveBeenCalled() + } finally { + release() + await pending + } +}) + +it.each(actions)('cancels $name preparation with the caller', async ({ run }) => { + const c = commands() + const controller = new AbortController() + let release = () => {} + const held = new Promise((resolve) => { + release = () => resolve(target) + }) + vi.mocked(c.resolveLinearAgentWriteTarget).mockReturnValue(held) + vi.mocked(c.resolveLinearCreateTeam).mockImplementation(async () => { + await held + return team + }) + let outcome: unknown + const pending = run(c, controller.signal).catch((error) => { + outcome = error + }) + try { + controller.abort() + await vi.advanceTimersByTimeAsync(0) + expect(outcome).toMatchObject({ code: 'linear_timeout' }) + release() + await pending + await vi.advanceTimersByTimeAsync(0) + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + expect(mutations.createIssueForAgent).not.toHaveBeenCalled() + expect(comments.addIssueCommentForAgent).not.toHaveBeenCalled() + expect(comments.createIssueAttachment).not.toHaveBeenCalled() + expect(relations.writeIssueRelation).not.toHaveBeenCalled() + } finally { + release() + await pending + } +}) + +it('returns confirmed task fields without a second issue lookup', async () => { + const c = commands() + const confirmed = { ...issue, priority: 2 } + vi.mocked(c.readLinearAgentIssueWriteRecord) + .mockResolvedValueOnce(issue) + .mockRejectedValue(new Error('unexpected second lookup')) + vi.spyOn(mutations, 'updateIssueForAgent').mockResolvedValue(confirmed) + await expect( + c.linearIssueUpdateTask({ input: 'ENG-1', operation: 'priority', priority: 2 }) + ).resolves.toMatchObject({ current: { priority: 2 }, meta: { alreadySet: false } }) + expect(c.readLinearAgentIssueWriteRecord).toHaveBeenCalledTimes(1) +}) + +it.each(['stalled', 'rejected'])( + 'returns confirmed success while notification is %s', + async (state) => { + vi.spyOn(mutations, 'updateIssueForAgent').mockResolvedValue({ + ...issue, + state: { id: 'done', name: 'Done' } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + let release = () => {} + const notification = new Promise<[]>((resolve) => { + release = () => resolve([]) + }) + const c = commands(() => + state === 'stalled' ? notification : Promise.reject(new Error('notification unavailable')) + ) + let outcome: unknown + const pending = c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }).then( + (value) => { + outcome = value + }, + (error) => { + outcome = error + } + ) + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome).toMatchObject({ state: { id: 'done' } }) + } finally { + release() + await pending + } + } +) + +it.each(['comment', 'attach', 'create'] as const)( + 'keeps %s duplicate-id recovery within the remaining action lifetime', + async (verb) => { + const writeId = '11111111-1111-4111-8111-111111111111' + for (const expires of [false, true]) { + const c = commands() + const record = { + id: writeId, + url: issue.url, + body: 'body', + title: 'Link', + issue, + parentId: null, + threadRootId: writeId + } + let releaseRecovery = () => {} + let releaseMutation = () => {} + const recovery = new Promise((resolve) => { + releaseRecovery = resolve + }) + const mutation = new Promise((resolve) => { + releaseMutation = resolve + }) + vi.spyOn(c, 'getMatchingLinearCommentWrite') + .mockResolvedValueOnce(null) + .mockImplementation(async () => { + await recovery + return record + }) + vi.spyOn(c, 'getMatchingLinearAttachmentWrite') + .mockResolvedValueOnce(null) + .mockImplementation(async () => { + await recovery + return record + }) + vi.spyOn(c, 'getMatchingLinearCreatedIssue') + .mockResolvedValueOnce(null) + .mockImplementation(async () => { + await recovery + return { ...issue, id: writeId } + }) + const duplicate = async (beforeInvoke?: () => void) => { + beforeInvoke?.() + await mutation + throw new LinearWriteFailure('duplicate_id', 'Already exists') + } + vi.mocked(comments.addIssueCommentForAgent).mockImplementation( + (_id, _body, _workspace, options) => duplicate(options.beforeInvoke) + ) + vi.mocked(comments.createIssueAttachment).mockImplementation( + (_id, _input, _workspace, options) => duplicate(options?.beforeInvoke) + ) + vi.mocked(mutations.createIssueForAgent).mockImplementation( + (_team, _title, _body, _workspace, options) => duplicate(options.beforeInvoke) + ) + const invoke = () => + verb === 'comment' + ? c.linearIssueAddComment({ input: 'ENG-1', body: 'body', writeId }) + : verb === 'attach' + ? c.linearIssueAttachLink({ input: 'ENG-1', url: issue.url, writeId }) + : c.linearIssueCreate({ teamInput: 'ENG', title: 'Title', writeId }) + let outcome: unknown + const pending = invoke().then( + (value) => { + outcome = value + }, + (error) => { + outcome = error + } + ) + const provider = + verb === 'comment' + ? comments.addIssueCommentForAgent + : verb === 'attach' + ? comments.createIssueAttachment + : mutations.createIssueForAgent + try { + await vi.advanceTimersByTimeAsync(20_000) + releaseMutation() + await vi.advanceTimersByTimeAsync(0) + if (expires) { + await vi.advanceTimersByTimeAsync(5_000) + expect(outcome).toMatchObject({ + code: 'linear_write_unconfirmed', + data: { + writeId, + cause: 'Linear write deadline elapsed before confirmation.', + nextSteps: [expect.stringContaining(`--write-id=${writeId}`)] + } + }) + } else { + releaseRecovery() + await pending + expect(outcome).toMatchObject({ meta: { writeId, deduplicated: true } }) + } + expect(provider).toHaveBeenCalledTimes(1) + } finally { + releaseMutation() + releaseRecovery() + await pending + await vi.advanceTimersByTimeAsync(0) + } + expect(provider).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + vi.mocked(provider).mockClear() + } + } +) + +it.each(['write', 'total'] as const)( + 'reports a truthful cause when the %s deadline ends an invoked write', + async (deadline) => { + const c = commands() + const timers = vi.spyOn(globalThis, 'setTimeout') + let release = () => {} + const confirmation = new Promise((resolve) => { + release = () => resolve({ ...issue, state: { id: 'done', name: 'Done' } }) + }) + vi.mocked(mutations.updateIssueForAgent).mockImplementation( + (_id, _fields, _workspace, options) => { + options?.beforeInvoke?.() + return confirmation + } + ) + let outcome: unknown + const pending = c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }).catch((error) => { + outcome = error + }) + try { + await vi.advanceTimersByTimeAsync(0) + expect(mutations.updateIssueForAgent).toHaveBeenCalledTimes(1) + if (deadline === 'write') { + await vi.advanceTimersByTimeAsync(25_000) + } else { + const totalTimeout = timers.mock.calls.find(([, delay]) => delay === 55_000)?.[0] + if (typeof totalTimeout !== 'function') { + throw new Error('Total deadline not scheduled') + } + vi.setSystemTime(Date.now() + 55_000) + // Deliver the real total callback independently of the earlier write timer. + totalTimeout() + await vi.advanceTimersByTimeAsync(0) + } + await pending + expect(outcome).toMatchObject({ + code: 'linear_write_unconfirmed', + data: { + cause: 'Linear write deadline elapsed before confirmation.', + nextSteps: [expect.stringContaining('orca linear issue ENG-1')] + } + }) + } finally { + release() + await pending + await vi.advanceTimersByTimeAsync(0) + } + expect(mutations.updateIssueForAgent).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } +) + +it.each(['status', 'relation'] as const)( + 'reports %s uncertainty only after invocation when the caller disconnects', + async (verb) => { + const c = commands() + const caller = new AbortController() + let release = () => {} + const confirmation = new Promise((_resolve, reject) => { + release = () => reject(new Error('lost confirmation')) + }) + vi.mocked(mutations.updateIssueForAgent).mockImplementation( + (_id, _fields, _workspace, options) => { + options?.beforeInvoke?.() + return confirmation + } + ) + vi.mocked(relations.writeIssueRelation).mockImplementation((params) => { + params.beforeInvoke?.() + return confirmation + }) + vi.mocked(c.resolveLinearAgentWriteTarget) + .mockResolvedValueOnce(target) + .mockResolvedValue({ ...target, issue: { ...issue, id: 'issue-2' } }) + const pending = + verb === 'status' + ? c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }, caller.signal) + : c.linearIssueRelationWrite( + { input: 'ENG-1', relatedInput: 'ENG-2', relationship: 'blocks', operation: 'add' }, + caller.signal + ) + const rejected = expect(pending).rejects.toMatchObject({ + code: 'linear_write_unconfirmed', + data: { nextSteps: [expect.stringContaining('orca linear issue ENG-1')] } + }) + try { + await vi.advanceTimersByTimeAsync(0) + caller.abort() + await rejected + expect( + verb === 'status' ? mutations.updateIssueForAgent : relations.writeIssueRelation + ).toHaveBeenCalledTimes(1) + } finally { + release() + await pending.catch(() => {}) + } + } +) + +it.each(['comment', 'attach', 'create'] as const)( + 'returns a matching %s replay without attempting a mutation', + async (verb) => { + const c = commands() + const writeId = '11111111-1111-4111-8111-111111111111' + const record = { + id: writeId, + url: issue.url, + body: 'body', + title: 'Link', + issue, + parentId: null, + threadRootId: writeId + } + vi.spyOn(c, 'getMatchingLinearCommentWrite').mockResolvedValue(record) + vi.spyOn(c, 'getMatchingLinearAttachmentWrite').mockResolvedValue(record) + vi.spyOn(c, 'getMatchingLinearCreatedIssue').mockResolvedValue({ ...issue, id: writeId }) + const pending = + verb === 'comment' + ? c.linearIssueAddComment({ input: 'ENG-1', body: 'body', writeId }) + : verb === 'attach' + ? c.linearIssueAttachLink({ input: 'ENG-1', url: issue.url, writeId }) + : c.linearIssueCreate({ teamInput: 'ENG', title: 'Title', writeId }) + await expect(pending).resolves.toMatchObject({ meta: { writeId, deduplicated: true } }) + expect(comments.addIssueCommentForAgent).not.toHaveBeenCalled() + expect(comments.createIssueAttachment).not.toHaveBeenCalled() + expect(mutations.createIssueForAgent).not.toHaveBeenCalled() + } +) diff --git a/src/main/runtime/runtime-rpc-local-disconnect-forwarding.test.ts b/src/main/runtime/runtime-rpc-local-disconnect-forwarding.test.ts index f0217946451..cd435476696 100644 --- a/src/main/runtime/runtime-rpc-local-disconnect-forwarding.test.ts +++ b/src/main/runtime/runtime-rpc-local-disconnect-forwarding.test.ts @@ -11,47 +11,86 @@ import { installOrcadFeatureReadiness } from './rpc/orcad-feature-readiness' -it.each(['browser.eval', 'linear.commentEdit', 'linear.commentDelete'] as const)( - 'forwards local disconnect to %s without long-poll keepalives', - async (method) => { - const userDataPath = mkdtempSync(join(tmpdir(), 'local-disconnect-forwarding-')) - const entered = vi.fn() - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - methods: [ - defineMethod({ - name: method, - permission: 'workspace', - params: null, - handler: async (_params, { signal }) => { - entered(signal) - await new Promise((resolve) => - signal?.addEventListener('abort', () => resolve(), { once: true }) - ) - return { cancelled: signal?.aborted } - } - }) - ] - }) - const cancellation = new AbortController() - const startKeepalive = vi.fn() - try { - const pending = server['handleMessage']( - JSON.stringify({ id: 'req-1', method, authToken: server['authToken'] }), - { signal: cancellation.signal, startKeepalive } - ) - await vi.waitFor(() => expect(entered).toHaveBeenCalledWith(cancellation.signal)) - cancellation.abort() - await expect(pending).resolves.toMatchObject({ ok: true, result: { cancelled: true } }) - expect(startKeepalive).not.toHaveBeenCalled() - } finally { - cancellation.abort() - await server.stop() - rmSync(userDataPath, { recursive: true, force: true }) - } +it.each([ + 'browser.eval', + 'linear.saveIssue', + 'linear.issueSetState', + 'linear.issueUpdateTask', + 'linear.issueRelationWrite', + 'linear.issueAddComment', + 'linear.issueAttachLink', + 'linear.issueCreate', + 'linear.commentEdit', + 'linear.commentDelete' +] as const)('forwards local disconnect to %s without long-poll keepalives', async (method) => { + const userDataPath = mkdtempSync(join(tmpdir(), 'local-disconnect-forwarding-')) + const entered = vi.fn() + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + methods: [ + defineMethod({ + name: method, + permission: 'workspace', + params: null, + handler: async (_params, { signal }) => { + entered(signal) + await new Promise((resolve) => + signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + return { cancelled: signal?.aborted } + } + }) + ] + }) + const cancellation = new AbortController() + const startKeepalive = vi.fn() + try { + const pending = server['handleMessage']( + JSON.stringify({ id: 'req-1', method, authToken: server['authToken'] }), + { signal: cancellation.signal, startKeepalive } + ) + await vi.waitFor(() => expect(entered).toHaveBeenCalledWith(cancellation.signal)) + cancellation.abort() + await expect(pending).resolves.toMatchObject({ ok: true, result: { cancelled: true } }) + expect(startKeepalive).not.toHaveBeenCalled() + } finally { + cancellation.abort() + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) } -) +}) + +it('preserves non-Linear unary signal ownership', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'linear-write-namespace-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + methods: [ + defineMethod({ + name: 'plugins.panelAction', + permission: 'workspace', + params: null, + handler: async (_params, { signal }) => ({ hasSignal: signal !== undefined }) + }) + ] + }) + try { + await expect( + server['handleMessage']( + JSON.stringify({ + id: 'req-1', + method: 'plugins.panelAction', + authToken: server['authToken'] + }), + { signal: new AbortController().signal, startKeepalive: vi.fn() } + ) + ).resolves.toMatchObject({ ok: true, result: { hasSignal: false } }) + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } +}) // A short RPC gets no handler signal, but a caller that disconnects mid feature-wait is never run. it('refuses a gated short RPC whose local caller disconnects while it waits', async () => { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts index e676227c9e9..8c431c6533a 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts @@ -45,7 +45,9 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { try { return await this.dispatcher.dispatch(request, { signal: - longPoll || DISCONNECT_FORWARDED_METHODS.has(request.method) + longPoll || + request.method.startsWith('linear.') || + DISCONNECT_FORWARDED_METHODS.has(request.method) ? context?.signal : undefined, connectionSignal: context?.signal, diff --git a/tests/e2e/linear-write-lifetime.unit.test.ts b/tests/e2e/linear-write-lifetime.unit.test.ts new file mode 100644 index 00000000000..9a4b81846e0 --- /dev/null +++ b/tests/e2e/linear-write-lifetime.unit.test.ts @@ -0,0 +1,448 @@ +import '../../src/main/runtime/rpc/unused-default-rpc-methods.test-fixture' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { clearTimeout as clearRealTimeout, setTimeout as setRealTimeout } from 'node:timers' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { main } from '../../src/cli/index' +import { RuntimeClient } from '../../src/cli/runtime-client' +import * as mutations from '../../src/main/linear/linear-issue-mutations' +import * as comments from '../../src/main/linear/linear-issue-comments' +import * as relations from '../../src/main/linear/issue-relation-write' +import { LinearWriteFailure } from '../../src/main/linear/linear-issue-write-support' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { OrcaRuntimeRpcServer } from '../../src/main/runtime/runtime-rpc' +import { LINEAR_AGENT_ACCESS_METHODS } from '../../src/main/runtime/rpc/methods/linear-agent-access' + +const writeId = '11111111-1111-4111-8111-111111111111' +const issue = { + id: 'issue-1', + identifier: 'ENG-1', + title: 'Title', + description: '', + url: 'https://linear.app/acme/issue/ENG-1', + team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, + state: { id: 'todo', name: 'Todo' }, + parent: null, + project: null, + assignee: null, + priority: 0, + estimate: null, + dueDate: null, + labelIds: [], + labels: [] +} +const target = { issue, workspaceId: 'w1' } +const comment = { + id: writeId, + url: null, + body: 'old', + issue, + parentId: null, + threadRootId: writeId +} +let runtime: OrcaRuntimeService +let server: OrcaRuntimeRpcServer +let userDataPath: string +let failure: LinearWriteFailure + +async function observe(pending: Promise, phase: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + timer = setRealTimeout(() => reject(new Error(`Timed out observing ${phase}`)), 2_000) + }) + ]) + } finally { + clearRealTimeout(timer) + } +} + +beforeEach(async () => { + userDataPath = mkdtempSync(join(tmpdir(), 'linear-write-cli-')) + vi.stubEnv('ORCA_USER_DATA_PATH', userDataPath) + for (const name of [ + 'ORCA_PAIRING_CODE', + 'ORCA_REMOTE_PAIRING', + 'ORCA_ENVIRONMENT', + 'ORCA_WORKTREE_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_RUNTIME_SOURCE_ID' + ]) { + vi.stubEnv(name, '') + } + process.exitCode = 0 + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'resolveLinearAgentWriteTarget').mockImplementation(async (params) => + params.input === 'ENG-2' + ? { ...target, issue: { ...issue, id: 'issue-2', identifier: 'ENG-2' } } + : target + ) + vi.spyOn(runtime, 'resolveLinearCreateTeam').mockResolvedValue({ + ...issue.team, + workspaceId: 'w1' + }) + vi.spyOn(runtime, 'getLinearTeamStatesForWrite').mockResolvedValue([ + { id: 'done', name: 'Done', type: 'completed', color: '#888888', position: 1 } + ]) + vi.spyOn(runtime, 'readLinearAgentIssueWriteRecord').mockResolvedValue(issue) + vi.spyOn(runtime, 'getLinearTeamLabelsForWrite').mockResolvedValue([ + { id: 'bug', name: 'Bug', color: '#888888' } + ]) + vi.spyOn(runtime, 'getLinearViewerForWrite').mockResolvedValue({ id: 'user-1' }) + vi.spyOn(runtime, 'resolveLinearAgentComment').mockResolvedValue({ comment, workspaceId: 'w1' }) + vi.spyOn(runtime, 'getMatchingLinearCommentWrite').mockResolvedValue(null) + vi.spyOn(runtime, 'getMatchingLinearAttachmentWrite').mockResolvedValue(null) + vi.spyOn(runtime, 'getMatchingLinearCreatedIssue').mockResolvedValue(null) + vi.spyOn(runtime, 'notifyLinearLinkedIssueUpdated').mockReturnValue(undefined) + failure = new LinearWriteFailure('unconfirmed', 'Confirmation lost') + const lost = () => { + throw failure + } + vi.spyOn(mutations, 'updateIssueForAgent').mockImplementation( + async (_id, _fields, _workspace, options) => { + options?.beforeInvoke?.() + return lost() + } + ) + vi.spyOn(mutations, 'createIssueForAgent').mockImplementation( + async (_team, _title, _body, _workspace, options) => { + options.beforeInvoke?.() + return lost() + } + ) + vi.spyOn(comments, 'addIssueCommentForAgent').mockImplementation( + async (_id, _body, _workspace, options) => { + options.beforeInvoke?.() + return lost() + } + ) + vi.spyOn(comments, 'createIssueAttachment').mockImplementation( + async (_id, _input, _workspace, options) => { + options?.beforeInvoke?.() + return lost() + } + ) + vi.spyOn(comments, 'updateIssueCommentForAgent').mockImplementation( + async (_id, _body, _workspace, options) => { + options?.beforeInvoke?.() + return lost() + } + ) + vi.spyOn(comments, 'deleteIssueCommentForAgent').mockImplementation( + async (_id, _workspace, options) => { + options?.beforeInvoke?.() + return lost() + } + ) + vi.spyOn(relations, 'writeIssueRelation').mockImplementation(async (params) => { + params.beforeInvoke?.() + return lost() + }) + server = new OrcaRuntimeRpcServer({ runtime, userDataPath, methods: LINEAR_AGENT_ACCESS_METHODS }) + await server.start() +}) + +afterEach(async () => { + vi.useRealTimers() + await server.stop() + vi.restoreAllMocks() + vi.unstubAllEnvs() + process.exitCode = 0 + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const verbs = [ + ['status', 'set', 'ENG-1', '--to', 'Done'], + ['priority', 'set', 'ENG-1', '--to', 'high'], + ['priority', 'clear', 'ENG-1'], + ['assignee', 'set', 'ENG-1', '--to-id', writeId], + ['assignee', 'set', 'ENG-1', '--me'], + ['assignee', 'clear', 'ENG-1'], + ['label', 'add', 'ENG-1', '--label', 'Bug'], + ['label', 'remove', 'ENG-1', '--label', 'Bug'], + ['label', 'set', 'ENG-1', '--label', 'Bug'], + ['estimate', 'set', 'ENG-1', '--to', '3'], + ['estimate', 'clear', 'ENG-1'], + ['due-date', 'set', 'ENG-1', '--to', '2026-11-01'], + ['due-date', 'clear', 'ENG-1'], + ['relation', 'add', 'ENG-1', '--related', 'ENG-2', '--type', 'blocks'], + ['relation', 'remove', 'ENG-1', '--related', 'ENG-2', '--type', 'blocks'], + ['comment', 'add', 'ENG-1', '--body', 'new', '--write-id', writeId], + ['comment', 'edit', writeId, '--body', 'new'], + ['comment', 'delete', writeId], + ['attach', 'ENG-1', '--url', 'https://example.com', '--write-id', writeId], + ['create', '--title', 'New', '--team', 'ENG', '--write-id', writeId], + ['save-issue', 'ENG-1', '--title', 'New'], + ['save-issue', '--title', 'New', '--team', 'ENG', '--write-id', writeId] +].map((argv) => ({ name: argv.join(' '), argv })) + +it.each(verbs)( + 'preserves $name recovery through CLI, socket, admission and dispatch', + async ({ argv }) => { + if (argv[1] === 'clear' || argv[1] === 'remove') { + vi.mocked(runtime.readLinearAgentIssueWriteRecord).mockResolvedValue({ + ...issue, + priority: 2, + assignee: { id: 'user-2', displayName: 'Other' }, + estimate: 3, + dueDate: '2026-11-01', + labelIds: ['bug'], + labels: [{ id: 'bug', name: 'Bug' }] + }) + } + await main(['linear', ...argv, '--json'], '/tmp') + const envelope = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(envelope).toMatchObject({ + ok: false, + error: { code: 'linear_write_unconfirmed', data: { nextSteps: [expect.any(String)] } } + }) + const nextSteps: string[] = envelope.error.data.nextSteps + expect(nextSteps[0]).toContain('orca linear') + if (argv.includes('--write-id')) { + expect(nextSteps[0]).toContain(`--write-id=${writeId}`) + } + expect(process.exitCode).toBe(1) + vi.mocked(console.error).mockClear() + process.exitCode = 0 + await main(['linear', ...argv], '/tmp') + const stderr = vi + .mocked(console.error) + .mock.calls.map((call) => String(call[0])) + .join('\n') + for (const step of nextSteps) { + expect(stderr).toContain(step) + } + expect(process.exitCode).toBe(1) + } +) + +const classifiedFailures = [ + { + kind: 'failed', + diagnostic: 'GraphQL validation error: Unknown argument body (BAD_USER_INPUT)' + }, + { kind: 'network', diagnostic: 'getaddrinfo ENOTFOUND api.linear.app' }, + { kind: 'duplicate_id', diagnostic: 'Duplicate id on comment mutation (BAD_USER_INPUT)' } +] as const +const failureCases = verbs + .filter(({ argv }) => argv[0] === 'comment' && ['edit', 'delete'].includes(argv[1])) + .flatMap((command) => classifiedFailures.map((input) => ({ ...command, ...input }))) +const diagnosticControls = classifiedFailures + .filter(({ kind }) => kind !== 'duplicate_id') + .map((input) => ({ name: 'status set', argv: verbs[0].argv, ...input })) + +it.each([...failureCases, ...diagnosticControls])( + 'preserves the prior $name $kind failure contract through the real CLI', + async ({ argv, kind, diagnostic }) => { + failure = new LinearWriteFailure(kind, diagnostic) + const expectedMessage = + argv[0] === 'comment' + ? 'Linear could not complete the comment change. Retry after checking the comment.' + : diagnostic + await main(['linear', ...argv, '--json'], '/tmp') + const envelope = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(envelope).toMatchObject({ + ok: false, + error: { + code: kind === 'network' ? 'linear_network_error' : 'linear_write_failed', + message: expectedMessage + } + }) + expect(process.exitCode).toBe(1) + vi.mocked(console.error).mockClear() + process.exitCode = 0 + await main(['linear', ...argv], '/tmp') + const stderr = vi + .mocked(console.error) + .mock.calls.map((call) => String(call[0])) + .join('\n') + expect(stderr).toContain(expectedMessage) + if (argv[0] === 'comment') { + expect(stderr).not.toContain(diagnostic) + } + expect(process.exitCode).toBe(1) + } +) + +it('returns a confirmed CLI write over the unchanged wire payload', async () => { + vi.mocked(mutations.updateIssueForAgent).mockImplementation( + async (_id, _fields, _workspace, options) => { + options?.beforeInvoke?.() + return { ...issue, state: { id: 'done', name: 'Done' } } + } + ) + await main(['linear', 'status', 'set', 'ENG-1', '--to', 'Done', '--json'], '/tmp') + expect(JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))).toMatchObject({ + ok: true, + result: { state: { id: 'done' } } + }) + expect(mutations.updateIssueForAgent).toHaveBeenCalledTimes(1) + expect(process.exitCode).toBe(0) +}) + +it('ends a stalled CLI preparation before its client timeout without a late write', async () => { + let release = () => {} + let entered = () => {} + const started = new Promise((resolve) => { + entered = resolve + }) + vi.mocked(runtime.resolveLinearAgentWriteTarget).mockImplementation(() => { + entered() + return new Promise((resolve) => { + release = () => resolve(target) + }) + }) + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + const pending = main(['linear', 'status', 'set', 'ENG-1', '--to', 'Done', '--json'], '/tmp') + try { + await observe(started, 'preparation admission') + await vi.advanceTimersByTimeAsync(30_000) + await observe(pending, 'CLI preparation timeout') + expect(JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))).toMatchObject({ + error: { + code: 'linear_timeout', + message: 'The Linear action stopped before a change was attempted.' + } + }) + release() + await vi.advanceTimersByTimeAsync(0) + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + } finally { + release() + await observe(pending, 'CLI settlement') + } +}) + +it.each(['status', 'duplicate comment'] as const)( + 'exposes truthful invoked deadline cause and recovery in %s CLI JSON', + async (verb) => { + let release = () => {} + let entered = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const started = new Promise((resolve) => { + entered = resolve + }) + vi.mocked(mutations.updateIssueForAgent).mockImplementation( + async (_id, _fields, _workspace, options) => { + options?.beforeInvoke?.() + entered() + await held + return { ...issue, state: { id: 'done', name: 'Done' } } + } + ) + vi.mocked(comments.addIssueCommentForAgent).mockImplementation( + async (_id, _body, _workspace, options) => { + options.beforeInvoke?.() + throw new LinearWriteFailure('duplicate_id', 'Already exists') + } + ) + vi.mocked(runtime.getMatchingLinearCommentWrite) + .mockResolvedValueOnce(null) + .mockImplementation(async () => { + entered() + await held + return comment + }) + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + const argv = + verb === 'status' + ? ['status', 'set', 'ENG-1', '--to', 'Done'] + : ['comment', 'add', 'ENG-1', '--body', 'new', '--write-id', writeId] + const pending = main(['linear', ...argv, '--json'], '/tmp') + pending.catch(() => {}) + try { + await observe(started, 'invocation or duplicate recovery admission') + await vi.advanceTimersByTimeAsync(25_000) + await observe(pending, 'CLI confirmation deadline') + const envelope = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(envelope).toMatchObject({ + ok: false, + error: { + code: 'linear_write_unconfirmed', + data: { cause: 'Linear write deadline elapsed before confirmation.' } + } + }) + if (verb === 'status') { + expect(envelope.error.data.nextSteps).toEqual([ + expect.stringContaining('orca linear issue ENG-1') + ]) + } else { + expect(envelope.error.data).toMatchObject({ + writeId, + nextSteps: [expect.stringContaining(`--write-id=${writeId}`)] + }) + expect(runtime.getMatchingLinearCommentWrite).toHaveBeenCalledTimes(2) + } + expect(process.exitCode).toBe(1) + } finally { + release() + await vi.advanceTimersByTimeAsync(0) + await observe(pending, 'CLI settlement after late confirmation') + } + expect( + verb === 'status' ? mutations.updateIssueForAgent : comments.addIssueCommentForAgent + ).toHaveBeenCalledTimes(1) + expect(console.log).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } +) + +it('propagates real client socket timeout into preparation cancellation', async () => { + let release = () => {} + let entered = () => {} + let cancelled = () => {} + const held = new Promise((resolve) => { + release = () => resolve(target) + }) + const started = new Promise((resolve) => { + entered = resolve + }) + const aborted = new Promise((resolve) => { + cancelled = resolve + }) + vi.mocked(runtime.resolveLinearAgentWriteTarget).mockImplementation((_params, signal) => { + signal?.addEventListener('abort', () => cancelled(), { once: true }) + entered() + return held + }) + const dispatch = vi.spyOn(runtime, 'linearIssueSetState') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + const client = new RuntimeClient(userDataPath, 30, null, null) + const pending = client.call('linear.issueSetState', { input: 'ENG-1', to: 'Done' }).then( + (value) => ({ value }), + (error) => ({ error }) + ) + try { + await observe(started, 'preparation admission before client timeout') + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(30) + expect(await observe(pending, 'client timeout')).toMatchObject({ + error: { code: 'runtime_timeout' } + }) + await observe(aborted, 'socket-close cancellation at preparation') + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + release() + await new Promise((resolve) => setImmediate(resolve)) + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + } finally { + release() + await vi.advanceTimersByTimeAsync(30) + await observe(pending, 'client settlement') + const request = dispatch.mock.results[0] + if (request?.type === 'return') { + await observe( + Promise.resolve(request.value).catch(() => {}), + 'dispatch settlement' + ) + } + } + expect(mutations.updateIssueForAgent).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +})