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
This commit is contained in:
Brennan Benson
2026-10-29 13:15:07 -07:00
committed by GitHub
parent 14676d11fb
commit e69baa3499
40 changed files with 2532 additions and 959 deletions
+34 -19
View File
@@ -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<ResolvedIssue> {
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<T>(
entry: LinearClientForWorkspace,
read: () => Promise<T>,
selection?: (string & {}) | 'all'
selection?: (string & {}) | 'all',
signal?: AbortSignal
): Promise<T> {
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<T>(
async function readIssueWorkspace(
entry: LinearClientForWorkspace,
identifier: string
identifier: string,
signal?: AbortSignal
): Promise<ResolvedIssue | null> {
const response = await withLinearRead(entry, async () => {
const client = getPublicFileUrlClient(entry)
const raw = await client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
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<RawIssueResponse, Record<string, unknown>>(
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<ResolvedIssue[]> {
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)
+3 -3
View File
@@ -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)
}
@@ -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 () => {
+11 -5
View File
@@ -15,8 +15,10 @@ import { readOptionalIncludes } from './includes'
export async function readLinearIssueContext(
request: LinearIssueRequest,
resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise<CurrentIssueLink>
resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise<CurrentIssueLink>,
signal?: AbortSignal
): Promise<LinearIssueContextResult> {
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)
}
@@ -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()
}
})
+12 -1
View File
@@ -29,17 +29,20 @@ export async function writeIssueRelation(params: {
operation: 'add' | 'remove'
workspaceId: string
signal?: AbortSignal
beforeInvoke?: () => void
}): Promise<LinearIssueRelationWriteResult> {
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<LinearIssueRelationWriteResult['relation'] | null> {
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<RawRelationsResponse, Record<string, unknown>>(
direction === 'outbound' ? RELATIONS_QUERY : INVERSE_RELATIONS_QUERY,
+83 -60
View File
@@ -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<LinearCommentWriteRecord> {
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<LinearAttachmentWriteRecord> {
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<LinearCommentWriteRecord | null> {
signal?.throwIfAborted()
const result = await client.client.rawRequest<LinearCommentByUuidResponse, LinearRawVariables>(
COMMENT_BY_UUID_QUERY,
{ id }
@@ -218,8 +239,10 @@ async function readCommentWriteRecord(
async function readAttachmentWriteRecord(
client: LinearClient,
id: string
id: string,
signal?: AbortSignal
): Promise<LinearAttachmentWriteRecord | null> {
signal?.throwIfAborted()
const result = await client.client.rawRequest<LinearAttachmentByUuidResponse, LinearRawVariables>(
ATTACHMENT_BY_UUID_QUERY,
{ id }
+33 -22
View File
@@ -95,21 +95,26 @@ async function readIssueWithSignal(
export async function getIssueByUuidForAgent(
id: string,
workspaceId?: string | null
workspaceId?: string | null,
signal?: AbortSignal
): Promise<LinearIssueWriteRecord | null> {
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<LinearAttachmentWriteRecord | null> {
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
}
+85 -71
View File
@@ -103,6 +103,7 @@ export async function createIssueForAgent(
dueDate?: string | null
labelIds?: string[]
signal?: AbortSignal
beforeInvoke?: () => void
}
): Promise<LinearIssueWriteRecord> {
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<LinearIssueWriteRecord> {
signal?.throwIfAborted()
const result = await client.client.rawRequest<LinearIssueByUuidResponse, LinearRawVariables>(
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<LinearIssueWriteRecord> {
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<string, unknown> = {}
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<string, unknown> = {}
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
)
}
@@ -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()
}
}
}
)
@@ -17,20 +17,22 @@ export async function readCollection<T>(
key: string,
workspaceId: LinearWorkspaceSelection | null | undefined,
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>,
force = false
force = false,
signal?: AbortSignal
): Promise<LinearCollectionResult<T>> {
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<T>(
hasMore: results.some((result) => result.hasMore)
}
},
force
force,
signal
)
}
+10 -1
View File
@@ -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<T>(key: string, load: () => Promise<T>, force = false): Promise<T> {
export function coalesce<T>(
key: string,
load: () => Promise<T>,
force = false,
signal?: AbortSignal
): Promise<T> {
// Request-owned cancellation must neither join nor replace independent readers' work.
if (signal !== undefined) {
return load()
}
const existing = inFlight.get(key) as Promise<T> | undefined
if (existing && !force) {
return existing
+18 -10
View File
@@ -34,7 +34,8 @@ export async function listProjects(
query: string | undefined,
limit = 20,
workspaceId?: LinearWorkspaceSelection | null,
force = false
force = false,
signal?: AbortSignal
): Promise<LinearCollectionResult<LinearProjectSummary>> {
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<LinearProjectSummary[]> {
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<LinearProjectDetail | null> {
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
)
}
@@ -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<NonNullable<LinearProjectSummary['teams']>> {
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<LinearProjectSummary['teams']> = []
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
)
}
+25 -10
View File
@@ -39,10 +39,13 @@ type TeamStateNode = {
}
export async function fetchAllTeamsForWorkspace(
entry: LinearClientForWorkspace
entry: LinearClientForWorkspace,
signal?: AbortSignal
): Promise<LinearTeam[]> {
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<LinearConnectionPage<TeamStateNode>>
}): Promise<LinearWorkflowState[]> {
export async function fetchAllTeamStates(
team: {
states: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamStateNode>>
},
signal?: AbortSignal
): Promise<LinearWorkflowState[]> {
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<LinearConnectionPage<TeamLabelNode>>
}): Promise<LinearLabel[]> {
export async function fetchAllTeamLabels(
team: {
labels: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamLabelNode>>
},
signal?: AbortSignal
): Promise<LinearLabel[]> {
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<LinearConnectionPage<TeamMemberNode>>
}): Promise<LinearMember[]> {
export async function fetchAllTeamMembers(
team: {
members: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamMemberNode>>
},
signal?: AbortSignal
): Promise<LinearMember[]> {
const members = await team.members({ first: TEAM_PAGE_SIZE })
while (members.pageInfo.hasNextPage) {
signal?.throwIfAborted()
await members.fetchNext()
}
return members.nodes.map((m) => ({
+1 -1
View File
@@ -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 })
}
+28 -18
View File
@@ -49,18 +49,19 @@ export async function listTeams(
}
export async function listTeamsOrThrow(
workspaceId?: LinearWorkspaceSelection | null
workspaceId?: LinearWorkspaceSelection | null,
signal?: AbortSignal
): Promise<LinearTeam[]> {
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<T>(
teamId: string,
workspaceId: string | null | undefined,
fetchAll: (team: Team) => Promise<T[]>,
fallbackWarning?: string
fetchAll: (team: Team, signal?: AbortSignal) => Promise<T[]>,
fallbackWarning?: string,
signal?: AbortSignal
): Promise<T[]> {
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<LinearWorkflowState[]> {
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<LinearLabel[]> {
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<LinearMember[]> {
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,
+11 -5
View File
@@ -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)
})
@@ -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<T>(
write: (signal: AbortSignal) => Promise<T>,
unconfirmed: (cause?: string) => Error
): Promise<T>
function runWrite(
runtime: OrcaRuntimeService,
write: (signal: AbortSignal) => Promise<unknown>,
unconfirmed: (cause?: string) => LinearAgentAccessError
): Promise<unknown> {
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<boolean>
notifyLinearLinkedIssueUpdated(
workspaceId: string,
identifier: string | readonly string[]
): Promise<void>
notifyLinearLinkedIssueUpdated(workspaceId: string, identifier: string | readonly string[]): void
listResolvedWorktrees(): Promise<unknown[]>
}
@@ -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<string>(() => 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'
})
@@ -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)
})
]
@@ -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 () => {
@@ -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,
@@ -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<LinearCommentAddResult> {
async linearIssueAddComment(
params: LinearCommentAddRequest,
cancellation?: AbortSignal
): Promise<LinearCommentAddResult> {
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<LinearAttachResult> {
async linearIssueAttachLink(
params: LinearAttachRequest,
cancellation?: AbortSignal
): Promise<LinearAttachResult> {
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<LinearCommentEditResult> {
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<LinearCommentDeleteResult> {
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)) {
@@ -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()
@@ -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<string> {
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<T>(
write: (signal: AbortSignal) => Promise<T>,
unconfirmed: (cause?: string) => LinearAgentAccessError
): Promise<T> {
const controller = new AbortController()
const writePromise = write(controller.signal)
writePromise.catch(() => undefined)
let timer: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
writePromise,
new Promise<never>((_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<Owner, Result>(
prepare: (signal: AbortSignal) => Promise<Owner>,
write: (
owner: Owner,
options: { signal: AbortSignal; beforeInvoke: () => void }
) => Promise<Result>,
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 {
+110 -100
View File
@@ -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<LinearCreateResult> {
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<LinearCreateResult> {
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)
}
}
@@ -18,10 +18,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
issueId: string,
parentId: string | null,
workspaceId: string,
required: boolean
required: boolean,
signal?: AbortSignal
): Promise<Awaited<ReturnType<typeof getLinearCommentByUuidForAgent>> | 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<Awaited<ReturnType<typeof getLinearAttachmentByUuidForAgent>> | 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<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>> | 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<NonNullable<Awaited<ReturnType<typeof getLinearCommentByUuidForAgent>>>> {
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<NonNullable<Awaited<ReturnType<typeof getLinearAttachmentByUuidForAgent>>>> {
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<NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>> {
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
@@ -13,9 +13,11 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo
public async resolveLinearLabelsForIssue(
issue: NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
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<Awaited<ReturnType<typeof getLinearTeamLabelsOrThrow>>> {
signal?.throwIfAborted()
try {
return await getLinearTeamLabelsOrThrow(teamId, workspaceId)
return await getLinearTeamLabelsOrThrow(teamId, workspaceId, signal)
} catch (error) {
throw this.mapLinearReadFailure(error)
}
@@ -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<LinearProjectSummary> {
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<LinearProjectSummary | null> {
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<LinearProjectSummary[]> {
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<LinearProjectSummary[]> {
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<void> {
signal?.throwIfAborted()
if (this.linearProjectIncludesTeam(project, teamId)) {
return
}
let teams: NonNullable<LinearProjectSummary['teams']> = []
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<LinearProjectSummary[]> {
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)
}
@@ -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<void> {
+119 -94
View File
@@ -13,7 +13,10 @@ import type {
import { RuntimeLinearCommentCommands } from './runtime-linear-comment-commands'
export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands {
async linearSaveIssue(params: LinearSaveIssueRequest): Promise<LinearSaveIssueResult> {
async linearSaveIssue(
params: LinearSaveIssueRequest,
cancellation?: AbortSignal
): Promise<LinearSaveIssueResult> {
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<LinearIssueTaskUpdateResult> {
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,
@@ -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<LinearCreateFieldIntent> {
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
+117 -103
View File
@@ -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<LinearStatusSetResult> {
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<LinearStatusSetResult> {
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<LinearIssueRelationWriteResult> {
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
}
}
@@ -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<Awaited<ReturnType<typeof getLinearTeamStatesOrThrow>>> {
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<NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>> {
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<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
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<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
workspaceId: string
workspaceId: string,
signal?: AbortSignal
): Promise<LinearIssueUpdate> {
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<string> {
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<ReturnType<typeof getLinearTeamMembersOrThrow>>
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<LinearAgentWriteTarget> {
public async resolveLinearAgentWriteTarget(
params: {
input?: string
current?: boolean
workspaceId?: string
context?: LinearCurrentIssueContextHints
},
signal?: AbortSignal
): Promise<LinearAgentWriteTarget> {
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 }
}
@@ -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<ReturnType<typeof listLinearTeamsOrThrow>>
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<ReturnType<typeof listLinearTeamsOrThrow>>
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<ReturnType<typeof listLinearTeamsOrThrow>>
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<ReturnType<typeof getLinearCurrentIssueFromWorktree>> {
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(
@@ -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<Owner, Result>(
export async function runLinearWriteAction<Owner, Result>(
resolveOwner: (signal: AbortSignal) => Promise<Owner>,
write: (
owner: Owner,
options: { signal: AbortSignal; beforeInvoke: () => void }
) => Promise<Result>,
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<Owner, Result>(
}, 30_000)
let writeTimer: ReturnType<typeof setTimeout> | 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, Result>(
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)
@@ -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<typeof commands>
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<unknown> }[] = [
{
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<typeof target>((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<typeof target>((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<void>((resolve) => {
releaseRecovery = resolve
})
const mutation = new Promise<void>((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<typeof issue>((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<never>((_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()
}
)
@@ -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<void>((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<void>((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 () => {
@@ -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,
@@ -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<T>(pending: Promise<T>, phase: string): Promise<T> {
let timer: ReturnType<typeof setRealTimeout> | undefined
try {
return await Promise.race([
pending,
new Promise<never>((_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<void>((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<void>((resolve) => {
release = resolve
})
const started = new Promise<void>((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<typeof target>((resolve) => {
release = () => resolve(target)
})
const started = new Promise<void>((resolve) => {
entered = resolve
})
const aborted = new Promise<void>((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)
})