@andypai/agent-kanban 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,7 +67,7 @@ function makeIssue(
67
67
  }
68
68
  }
69
69
 
70
- function standardRoutes(): StubRoute[] {
70
+ function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
71
71
  const issues = [makeIssue()]
72
72
  const comments: Record<
73
73
  string,
@@ -100,6 +100,19 @@ function standardRoutes(): StubRoute[] {
100
100
  match: (url) => url.includes('/rest/api/3/project/ENG'),
101
101
  handler: () => jsonResponse(projectFixture),
102
102
  },
103
+ {
104
+ match: (url) => url.includes('/rest/agile/1.0/board/'),
105
+ handler: () =>
106
+ jsonResponse(
107
+ opts.boardCfg ?? {
108
+ id: 1006,
109
+ name: 'ENG Board',
110
+ columnConfig: {
111
+ columns: [{ name: 'Done', statuses: [{ id: '10' }, { id: '20' }] }],
112
+ },
113
+ },
114
+ ),
115
+ },
103
116
  {
104
117
  match: (url) => url.includes('/rest/api/3/user/assignable/search'),
105
118
  handler: () => jsonResponse([{ accountId: 'a1', displayName: 'Alice', active: true }]),
@@ -132,6 +145,16 @@ function standardRoutes(): StubRoute[] {
132
145
  )
133
146
  },
134
147
  },
148
+ {
149
+ // GET /rest/api/3/issue/{key} backs hydrateIssueByKey's read-after-write.
150
+ match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+$/.test(new URL(url).pathname),
151
+ handler: (url) => {
152
+ const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)$/)![1]!
153
+ const issue = issues.find((candidate) => String(candidate.key) === issueKey)
154
+ if (!issue) return jsonResponse({ errorMessages: ['missing'] }, 404)
155
+ return jsonResponse(issue)
156
+ },
157
+ },
135
158
  {
136
159
  match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
137
160
  handler: async (url, init) => {
@@ -334,6 +357,51 @@ describe('postgres jira provider', () => {
334
357
  expect(body.fields?.labels).toEqual(['garage-smoke', 'garage-owner-local'])
335
358
  })
336
359
 
360
+ pgTest('keeps duplicate Jira board column names as distinct cached columns', async () => {
361
+ expect(sql).not.toBeNull()
362
+ if (!sql) throw new Error('expected postgres test connection')
363
+ const stub = jiraFetchStub(
364
+ standardRoutes({
365
+ boardCfg: {
366
+ id: 1006,
367
+ name: 'ENG Board',
368
+ columnConfig: {
369
+ columns: [
370
+ { name: 'Backlog', statuses: [{ id: '10' }] },
371
+ { name: 'Backlog', statuses: [{ id: '20' }] },
372
+ ],
373
+ },
374
+ },
375
+ }),
376
+ )
377
+ globalThis.fetch = stub.fn
378
+ const provider = new PostgresJiraProvider(sql, {
379
+ baseUrl: 'https://example.atlassian.net',
380
+ email: 'user@example.com',
381
+ apiToken: 'token',
382
+ projectKey: 'ENG',
383
+ boardId: 1006,
384
+ })
385
+
386
+ const board = await provider.getBoard()
387
+
388
+ expect(board.columns.map((column) => column.id)).toEqual([
389
+ 'board:1006:Backlog',
390
+ 'board:1006:Backlog:1',
391
+ ])
392
+ expect(board.columns.map((column) => column.tasks.map((task) => task.externalRef))).toEqual([
393
+ ['ENG-1'],
394
+ [],
395
+ ])
396
+ await expect(provider.listTasks({ column: 'Backlog' })).rejects.toMatchObject({
397
+ code: 'COLUMN_NOT_FOUND',
398
+ message: expect.stringContaining('ambiguous'),
399
+ })
400
+ expect(
401
+ (await provider.listTasks({ column: 'board:1006:Backlog' })).map((task) => task.id),
402
+ ).toEqual(['jira:10001'])
403
+ })
404
+
337
405
  pgTest('moves Jira tasks through Postgres storage', async () => {
338
406
  const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
339
407
 
@@ -343,4 +411,149 @@ describe('postgres jira provider', () => {
343
411
  column_id: '20',
344
412
  })
345
413
  })
414
+
415
+ pgTest(
416
+ 'delta sync adds new catalog rows but only a full reconcile prunes obsolete ones',
417
+ async () => {
418
+ if (!sql) throw new Error('expected postgres test connection')
419
+ const prioritiesState = {
420
+ list: [
421
+ { id: '2', name: 'High' },
422
+ { id: '3', name: 'Medium' },
423
+ ] as Array<{ id: string; name: string }>,
424
+ }
425
+ const routes = standardRoutes()
426
+ const pIdx = routes.findIndex((route) =>
427
+ route.match('https://example.atlassian.net/rest/api/3/priority'),
428
+ )
429
+ routes[pIdx] = {
430
+ match: (url) => url.includes('/rest/api/3/priority'),
431
+ handler: () => jsonResponse(prioritiesState.list),
432
+ }
433
+ globalThis.fetch = jiraFetchStub(routes).fn
434
+ // pollingSyncIntervalMs: 0 lets every getBoard run; the first is a full
435
+ // reconcile (no lastFullSyncAt yet), later ones are delta syncs because the
436
+ // full-reconcile interval has not elapsed.
437
+ const provider = new PostgresJiraProvider(sql, {
438
+ baseUrl: 'https://example.atlassian.net',
439
+ email: 'user@example.com',
440
+ apiToken: 'token',
441
+ projectKey: 'ENG',
442
+ boardId: 1006,
443
+ pollingSyncIntervalMs: 0,
444
+ })
445
+ const priorityNames = async () =>
446
+ (await sql!<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`).map(
447
+ (row) => row.name,
448
+ )
449
+
450
+ await provider.getBoard()
451
+ expect(await priorityNames()).toEqual(['High', 'Medium'])
452
+
453
+ // Drop Medium, add Low upstream. A delta sync must pick up the new row
454
+ // (Low) via the every-sync UPSERT but must NOT prune the now-obsolete row
455
+ // (Medium): pruning on a possibly-stale delta snapshot could delete a row
456
+ // another replica just added. Pruning is confined to the full reconcile.
457
+ prioritiesState.list = [
458
+ { id: '2', name: 'High' },
459
+ { id: '4', name: 'Low' },
460
+ ]
461
+ await provider.getBoard()
462
+ expect(await priorityNames()).toEqual(['High', 'Low', 'Medium'])
463
+
464
+ // Force the next sync to be a full reconcile; only then is the obsolete
465
+ // Medium pruned.
466
+ await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
467
+ await provider.getBoard()
468
+ expect(await priorityNames()).toEqual(['High', 'Low'])
469
+ },
470
+ )
471
+
472
+ pgTest('concurrent catalog refreshes do not collide on a primary key', async () => {
473
+ if (!sql) throw new Error('expected postgres test connection')
474
+ globalThis.fetch = jiraFetchStub(standardRoutes()).fn
475
+ const sql2 = postgres(databaseUrl as string, { max: 1, onnotice: () => {} })
476
+ try {
477
+ const config = {
478
+ baseUrl: 'https://example.atlassian.net',
479
+ email: 'user@example.com',
480
+ apiToken: 'token',
481
+ projectKey: 'ENG',
482
+ boardId: 1006,
483
+ pollingSyncIntervalMs: 0,
484
+ }
485
+ // Initialize schema sequentially first; concurrent CREATE TABLE IF NOT
486
+ // EXISTS races at the DDL level (a pre-existing Postgres quirk unrelated to
487
+ // catalog refresh). The race under test is the catalog DML below.
488
+ const p1 = new PostgresJiraProvider(sql, config)
489
+ await p1.initialize()
490
+ const p2 = new PostgresJiraProvider(sql2, config)
491
+ await p2.initialize()
492
+ // Two replicas on separate connections refreshing the same shared cache
493
+ // concurrently must not trip a primary-key collision. Both first syncs are
494
+ // full reconciles (no lastFullSyncAt yet), so this also exercises the
495
+ // obsolete-row DELETE running concurrently: UPSERT + conditional DELETE is
496
+ // idempotent and order-independent.
497
+ await Promise.all([p1.getBoard(), p2.getBoard()])
498
+ const rows = await sql<
499
+ { count: number }[]
500
+ >`SELECT COUNT(*)::int AS count FROM jira_priorities`
501
+ expect(rows[0]?.count ?? 0).toBeGreaterThan(0)
502
+ } finally {
503
+ await sql2.end({ timeout: 1 })
504
+ }
505
+ })
506
+
507
+ pgTest('full reconcile with a stalled cursor does not prune unfetched-page issues', async () => {
508
+ if (!sql) throw new Error('expected postgres test connection')
509
+ // Mirror of the SQLite prune-safety coverage: if the cursor stalls (a
510
+ // repeated non-last token) mid-scan, seenIssueIds is only partial, so the
511
+ // incomplete scan must NOT prune issues that exist upstream on pages we never
512
+ // fetched.
513
+ let stalled = false
514
+ const routes = standardRoutes()
515
+ const sIdx = routes.findIndex((route) => route.match('https://x/rest/api/3/search/jql'))
516
+ routes[sIdx] = {
517
+ match: (url) => url.includes('/rest/api/3/search/jql'),
518
+ handler: () =>
519
+ stalled
520
+ ? // Later full reconcile: the cursor stalls after returning only ENG-1,
521
+ // so ENG-2's page is never reached. isLast=false + a repeated token =>
522
+ // incomplete.
523
+ jsonResponse({
524
+ nextPageToken: 'stuck',
525
+ isLast: false,
526
+ issues: [makeIssue({ id: '1', key: 'ENG-1' })],
527
+ })
528
+ : // First full reconcile: a clean single terminal page with both issues.
529
+ jsonResponse({
530
+ isLast: true,
531
+ issues: [makeIssue({ id: '1', key: 'ENG-1' }), makeIssue({ id: '2', key: 'ENG-2' })],
532
+ }),
533
+ }
534
+ globalThis.fetch = jiraFetchStub(routes).fn
535
+ const provider = new PostgresJiraProvider(sql, {
536
+ baseUrl: 'https://example.atlassian.net',
537
+ email: 'user@example.com',
538
+ apiToken: 'token',
539
+ projectKey: 'ENG',
540
+ boardId: 1006,
541
+ pollingSyncIntervalMs: 0,
542
+ })
543
+ const issueKeys = async () =>
544
+ (await sql!<{ key: string }[]>`SELECT key FROM jira_issues ORDER BY key`).map(
545
+ (row) => row.key,
546
+ )
547
+
548
+ await provider.getBoard()
549
+ expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
550
+
551
+ // Force the next sync to be a full reconcile and hit the stalled cursor.
552
+ stalled = true
553
+ await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
554
+ await provider.getBoard()
555
+
556
+ // ENG-2 must survive: the incomplete scan is not authoritative for pruning.
557
+ expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
558
+ })
346
559
  })
@@ -1,8 +1,10 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import { ErrorCode, KanbanError } from '../errors'
2
3
  import type { BoardView, ProviderTeamInfo, Task } from '../types'
3
4
 
4
5
  // Column ids are prefixed to avoid collisions across sources:
5
- // - board-sourced columns: 'board:<boardId>:<columnName>'
6
+ // - board-sourced columns: 'board:<boardId>:<columnName>' with an index suffix
7
+ // only when Jira returns duplicate board column names
6
8
  // - status-fallback columns: 'status:<statusId>'
7
9
  // The provider (T04) picks ONE source per sync, so mixed-source boards
8
10
  // do not occur in practice.
@@ -30,6 +32,67 @@ export interface JiraCacheConfig {
30
32
  issueTypes: Array<{ id: string; name: string }>
31
33
  }
32
34
 
35
+ export interface JiraBoardColumnInput {
36
+ name: string
37
+ statuses: Array<{ id: string }>
38
+ }
39
+
40
+ export function jiraBoardColumnRows(
41
+ boardId: number,
42
+ columns: JiraBoardColumnInput[],
43
+ ): Array<{
44
+ id: string
45
+ name: string
46
+ position: number
47
+ statusIds: string[]
48
+ source: 'board'
49
+ }> {
50
+ const seen = new Set<string>()
51
+ return columns.map((column, index) => {
52
+ const baseId = `board:${boardId}:${column.name}`
53
+ let id = baseId
54
+ if (seen.has(id)) {
55
+ id = `${baseId}:${index}`
56
+ let suffix = 2
57
+ while (seen.has(id)) {
58
+ id = `${baseId}:${index}:${suffix}`
59
+ suffix += 1
60
+ }
61
+ }
62
+ seen.add(id)
63
+ return {
64
+ id,
65
+ name: column.name,
66
+ position: index,
67
+ statusIds: column.statuses.map((status) => status.id),
68
+ source: 'board',
69
+ }
70
+ })
71
+ }
72
+
73
+ // Resolves a user-supplied column reference to a cached column id, trying
74
+ // (1) exact id, (2) case-insensitive name, (3) raw status id containment.
75
+ // A name that matches multiple columns (possible when Jira returns duplicate
76
+ // board column names) is rejected as ambiguous so the caller picks an id.
77
+ export function resolveJiraColumnId(columns: JiraColumnRow[], input: string): string {
78
+ const byId = columns.find((column) => column.id === input)
79
+ if (byId) return byId.id
80
+ const lower = input.toLowerCase()
81
+ const byName = columns.filter((column) => column.name.toLowerCase() === lower)
82
+ if (byName.length === 1) return byName[0]!.id
83
+ if (byName.length > 1) {
84
+ throw new KanbanError(
85
+ ErrorCode.COLUMN_NOT_FOUND,
86
+ `Jira column name '${input}' is ambiguous; use one of these column ids: ${byName
87
+ .map((column) => column.id)
88
+ .join(', ')}`,
89
+ )
90
+ }
91
+ const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
92
+ if (byStatus) return byStatus.id
93
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
94
+ }
95
+
33
96
  interface JiraIssueRow {
34
97
  id: string
35
98
  key: string
@@ -45,12 +45,73 @@ export interface JiraIssue {
45
45
  }
46
46
 
47
47
  export interface JiraSearchPage {
48
- startAt: number
49
- maxResults: number
50
- total: number
48
+ // The legacy /rest/api/3/search endpoint returned startAt/maxResults/total.
49
+ // The current /rest/api/3/search/jql endpoint omits `total` and paginates by
50
+ // an opaque `nextPageToken` (with `isLast`), so these are optional now.
51
+ startAt?: number
52
+ maxResults?: number
53
+ total?: number
54
+ nextPageToken?: string
55
+ isLast?: boolean
51
56
  issues: JiraIssue[]
52
57
  }
53
58
 
59
+ export interface JiraPaginationDecision {
60
+ // When set, advance the scan to this cursor. When absent, the scan is over and
61
+ // `complete` says whether it ended definitively (safe to prune against the
62
+ // accumulated issue set) or was cut short (must NOT prune — issues on unfetched
63
+ // pages would be wrongly deleted).
64
+ nextToken?: string
65
+ complete: boolean
66
+ }
67
+
68
+ // Decide how a `/rest/api/3/search/jql` cursor scan should proceed after a page.
69
+ // The endpoint signals continuation with `isLast`/`nextPageToken`, but real and
70
+ // degraded servers vary, so termination must be conservative: a scan is reported
71
+ // `complete` (safe to prune against the accumulated issue set) ONLY when the
72
+ // server proves the end, never from a page-size guess.
73
+ // - usable cursor (fresh `nextPageToken`, isLast !== true) → advance.
74
+ // - isLast === true → definitive end (complete).
75
+ // - isLast === false → more pages exist; if the
76
+ // cursor is missing/stale we
77
+ // cannot fetch them, so the
78
+ // scan is incomplete.
79
+ // - isLast absent, `total` present → legacy total/startAt proof:
80
+ // complete once startAt+count
81
+ // reaches `total`.
82
+ // - isLast absent, no `total`, no usable cursor → NO completeness proof. A
83
+ // short/empty page must NOT be
84
+ // read as "the end": a degraded
85
+ // `{ issues: [] }` would then
86
+ // prune the entire cache on a
87
+ // full reconcile. Treat as
88
+ // incomplete and retry instead.
89
+ // A cursor already in `seenPageTokens` counts as not usable (a stalled cursor
90
+ // that would otherwise loop forever).
91
+ export function decideJiraPagination(
92
+ page: Pick<JiraSearchPage, 'isLast' | 'nextPageToken' | 'issues' | 'total' | 'startAt'>,
93
+ seenPageTokens: ReadonlySet<string>,
94
+ ): JiraPaginationDecision {
95
+ const token = page.nextPageToken
96
+ const canAdvance = !!token && page.isLast !== true && !seenPageTokens.has(token)
97
+ if (canAdvance) return { nextToken: token, complete: false }
98
+ if (page.isLast === true) return { complete: true }
99
+ if (page.isLast === false) return { complete: false }
100
+ // isLast absent below. Prefer the legacy total/startAt signal when a
101
+ // (non-standard) server supplies it: complete once we have fetched everything
102
+ // `total` promises. The live /search/jql endpoint omits `total` and ignores
103
+ // `startAt`, so the loop never advances by offset — a `total` promising more
104
+ // than this page is reported incomplete rather than fetched.
105
+ if (typeof page.total === 'number') {
106
+ const issueCount = page.issues?.length ?? 0
107
+ return { complete: (page.startAt ?? 0) + issueCount >= page.total }
108
+ }
109
+ // No completeness signal at all (no isLast, no total, no usable cursor). We
110
+ // cannot prove the scan reached the end, so never guess `complete` from page
111
+ // size — that would prune the whole cache on a degraded short/empty response.
112
+ return { complete: false }
113
+ }
114
+
54
115
  export interface JiraCreatePayload {
55
116
  fields: Record<string, unknown>
56
117
  }
@@ -254,12 +315,17 @@ export class JiraClient {
254
315
  startAt: number
255
316
  maxResults: number
256
317
  fields?: string[]
318
+ nextPageToken?: string
257
319
  }): Promise<JiraSearchPage> {
258
320
  const query: QueryParams = {
259
321
  jql: params.jql,
260
- startAt: params.startAt,
261
322
  maxResults: params.maxResults,
262
323
  }
324
+ // /rest/api/3/search/jql ignores startAt and paginates by nextPageToken.
325
+ // Send the cursor when we have one; only send startAt on the first page for
326
+ // back-compat with the legacy endpoint.
327
+ if (params.nextPageToken) query.nextPageToken = params.nextPageToken
328
+ else query.startAt = params.startAt
263
329
  if (params.fields && params.fields.length > 0) {
264
330
  query.fields = params.fields.join(',')
265
331
  }
@@ -20,7 +20,13 @@ import {
20
20
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
21
21
  import { JIRA_CAPABILITIES } from './capabilities'
22
22
  import { providerUpstreamError, unsupportedOperation } from './errors'
23
- import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
23
+ import {
24
+ JiraClient,
25
+ decideJiraPagination,
26
+ normalizeJiraLabels,
27
+ type JiraComment,
28
+ type JiraIssue,
29
+ } from './jira-client'
24
30
  import {
25
31
  adjustJiraIssueCommentCount,
26
32
  decodeColumnStatusIds,
@@ -32,6 +38,8 @@ import {
32
38
  getCachedTask,
33
39
  getCachedTasks,
34
40
  initJiraCacheSchema,
41
+ jiraBoardColumnRows,
42
+ resolveJiraColumnId,
35
43
  loadJiraSyncMeta,
36
44
  loadTeamInfo,
37
45
  pruneJiraIssuesMissingUpstream,
@@ -112,7 +120,11 @@ export class JiraProvider implements KanbanProvider {
112
120
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
113
121
  const now = Date.now()
114
122
  if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
115
- const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
123
+ // `force` only bypasses the poll throttle; it must NOT imply a full
124
+ // 1970-based reconcile, which re-fetches every issue plus a per-issue
125
+ // changelog call (~minutes). Writes get read-after-write freshness from
126
+ // hydrateIssueByKey instead, so a forced sync stays a cheap delta.
127
+ const fullReconcile = shouldRunFullReconcile(meta.lastFullSyncAt, now)
116
128
 
117
129
  // 1. Resolve project.
118
130
  const project = await this.client.getProject(this.config.projectKey)
@@ -122,13 +134,7 @@ export class JiraProvider implements KanbanProvider {
122
134
  if (this.config.boardId !== undefined) {
123
135
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
124
136
  const boardId = this.config.boardId
125
- const rows = boardCfg.columnConfig.columns.map((col, i) => ({
126
- id: `board:${boardId}:${col.name}`,
127
- name: col.name,
128
- position: i,
129
- statusIds: col.statuses.map((s) => s.id),
130
- source: 'board' as const,
131
- }))
137
+ const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
132
138
  replaceJiraColumns(this.db, rows)
133
139
  } else {
134
140
  const statusCats = await this.client.getProjectStatuses(project.key)
@@ -185,10 +191,7 @@ export class JiraProvider implements KanbanProvider {
185
191
  const sinceClause = since ?? '1970-01-01 00:00'
186
192
  const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
187
193
 
188
- let startAt = 0
189
194
  const maxResults = 100
190
- let accumulated = 0
191
- let total = Infinity
192
195
  let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
193
196
  const seenIssueIds = new Set<string>()
194
197
  const issueFields = [
@@ -204,12 +207,28 @@ export class JiraProvider implements KanbanProvider {
204
207
  'updated',
205
208
  'project',
206
209
  ]
207
- // Terminates when accumulated reaches total, or when the server returns
208
- // an empty page (defensive against buggy servers not advancing startAt).
209
- while (accumulated < total) {
210
- const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
211
- total = page.total
212
- if (page.issues.length === 0) break
210
+ // /rest/api/3/search/jql paginates by an opaque nextPageToken and omits
211
+ // `total`, so we follow the cursor until the server reports `isLast` or stops
212
+ // handing back a token. The previous total/startAt loop fetched only the first
213
+ // page (oldest 100 by updated ASC) once `total` came back undefined, so every
214
+ // issue beyond the first page — including newly-created tickets on a project
215
+ // with >100 issues — was never cached. seenPageTokens guards against a server
216
+ // that repeats a cursor, which would otherwise spin this loop forever and hang
217
+ // the poll cycle; an empty page is not treated as terminal because a non-last
218
+ // page may legitimately carry a token with zero issues.
219
+ const seenPageTokens = new Set<string>()
220
+ let nextPageToken: string | undefined
221
+ let firstPage = true
222
+ let paginationComplete = false
223
+ while (firstPage || nextPageToken !== undefined) {
224
+ firstPage = false
225
+ const page = await this.client.listIssues({
226
+ jql,
227
+ startAt: 0,
228
+ maxResults,
229
+ fields: issueFields,
230
+ nextPageToken,
231
+ })
213
232
 
214
233
  upsertJiraIssues(
215
234
  this.db,
@@ -253,10 +272,37 @@ export class JiraProvider implements KanbanProvider {
253
272
  })
254
273
  }
255
274
 
256
- accumulated += page.issues.length
257
- startAt += page.issues.length
275
+ const decision = decideJiraPagination(page, seenPageTokens)
276
+ if (decision.nextToken !== undefined) {
277
+ seenPageTokens.add(decision.nextToken)
278
+ nextPageToken = decision.nextToken
279
+ continue
280
+ }
281
+ paginationComplete = decision.complete
282
+ if (!paginationComplete) {
283
+ // The server stopped advancing the cursor before reporting a definitive
284
+ // end (stalled/contradictory). seenIssueIds is only a partial scan, so we
285
+ // must not treat it as authoritative for pruning below.
286
+ console.warn(
287
+ `[jira] search/jql scan ended without a definitive last page; treating as incomplete`,
288
+ )
289
+ }
290
+ break
291
+ }
292
+
293
+ if (!paginationComplete) {
294
+ // The scan ended early (stalled/contradictory cursor). Leave sync metadata
295
+ // unchanged so this partial result is not recorded as a clean sync: lastSyncAt
296
+ // is not advanced, so the next sync is not throttled and retries promptly, and
297
+ // the full-reconcile marker stays due. The issues we did fetch are already
298
+ // cached (additive); we only skip pruning — which would delete issues that
299
+ // exist upstream on pages we never fetched — and the metadata advance.
300
+ return
258
301
  }
259
302
 
303
+ // Prune against seenIssueIds and advance lastFullSyncAt only on a full
304
+ // reconcile; a delta sync's seenIssueIds is intentionally partial. (The scan
305
+ // is known complete here — an incomplete scan returned above.)
260
306
  if (fullReconcile) {
261
307
  pruneJiraIssuesMissingUpstream(this.db, project.key, [...seenIssueIds])
262
308
  }
@@ -275,18 +321,7 @@ export class JiraProvider implements KanbanProvider {
275
321
  }
276
322
 
277
323
  private resolveColumnId(input: string): string {
278
- const columns = getCachedColumns(this.db)
279
- // Priority 1: exact id.
280
- const byId = columns.find((c) => c.id === input)
281
- if (byId) return byId.id
282
- // Priority 2: case-insensitive name.
283
- const lower = input.toLowerCase()
284
- const byName = columns.find((c) => c.name.toLowerCase() === lower)
285
- if (byName) return byName.id
286
- // Priority 3: status_ids containment (raw status id).
287
- const byStatus = columns.find((c) => decodeColumnStatusIds(c).includes(input))
288
- if (byStatus) return byStatus.id
289
- throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
324
+ return resolveJiraColumnId(getCachedColumns(this.db), input)
290
325
  }
291
326
 
292
327
  private async buildBoardConfig(): Promise<BoardConfig> {
@@ -467,6 +502,55 @@ export class JiraProvider implements KanbanProvider {
467
502
  }
468
503
  }
469
504
 
505
+ // Read-after-write via the direct issue endpoint. Unlike JQL search (used by
506
+ // sync()), GET /issue/{key} has no search-index lag, so a just-created or
507
+ // just-transitioned issue is reflected immediately. This replaces the previous
508
+ // "create/transition then sync(true) then getCachedTask" pattern, which raced
509
+ // the search index (creates reported "not yet visible"; a move's new status
510
+ // sometimes didn't land) and forced a full whole-project reconcile per write.
511
+ private async hydrateIssueByKey(key: string): Promise<Task> {
512
+ // getIssue throws on a missing key (404), so reaching this method means the
513
+ // issue exists upstream. A null read-back would therefore be a genuine cache
514
+ // anomaly (the upsert above did not land), not an ordinary not-found — surface
515
+ // it rather than threading an unreachable null through every caller.
516
+ const issue = await this.client.getIssue(key)
517
+ upsertJiraIssues(this.db, [
518
+ {
519
+ id: issue.id,
520
+ key: issue.key,
521
+ summary: issue.fields.summary,
522
+ descriptionText: issue.fields.description
523
+ ? adfToPlainText(issue.fields.description as AdfDocument)
524
+ : '',
525
+ statusId: issue.fields.status.id,
526
+ priorityName: issue.fields.priority?.name ?? null,
527
+ issueTypeName: issue.fields.issuetype?.name ?? '',
528
+ assigneeAccountId: issue.fields.assignee?.accountId ?? null,
529
+ assigneeName: issue.fields.assignee?.displayName ?? null,
530
+ labels: issue.fields.labels ?? [],
531
+ commentCount: issue.fields.comment?.total ?? 0,
532
+ projectKey: issue.fields.project?.key ?? this.config.projectKey,
533
+ url: `${this.config.baseUrl}/browse/${issue.key}`,
534
+ createdAt: issue.fields.created,
535
+ updatedAt: issue.fields.updated,
536
+ },
537
+ ])
538
+ // Ingest the changelog like the sync loop does, so a just-applied transition
539
+ // is recorded in jira_activity immediately (backs getActivity and the
540
+ // poll-based `moved` trigger) rather than waiting for the next unthrottled
541
+ // sync. Best-effort: activity must not fail the mutation.
542
+ await this.ingestIssueActivity(issue.id).catch((err) => {
543
+ console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
544
+ })
545
+ const task = getCachedTask(this.db, key)
546
+ if (!task) {
547
+ providerUpstreamError(
548
+ `Jira issue ${key} was hydrated from GET /issue but is missing from the cache.`,
549
+ )
550
+ }
551
+ return task
552
+ }
553
+
470
554
  async createTask(input: CreateTaskInput): Promise<Task> {
471
555
  await this.sync()
472
556
  this.normalizeProjectField(input.project)
@@ -494,14 +578,7 @@ export class JiraProvider implements KanbanProvider {
494
578
  // issues land in the project workflow's default start state. Use
495
579
  // `moveTask` after create to change status.
496
580
  const created = await this.client.createIssue({ fields })
497
- await this.sync(true)
498
- const fresh = getCachedTask(this.db, created.key)
499
- if (!fresh) {
500
- providerUpstreamError(
501
- `Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
502
- )
503
- }
504
- return fresh
581
+ return this.hydrateIssueByKey(created.key)
505
582
  }
506
583
 
507
584
  async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
@@ -536,12 +613,7 @@ export class JiraProvider implements KanbanProvider {
536
613
  if (Object.keys(fields).length > 0) {
537
614
  await this.client.updateIssue(issueKey, { fields })
538
615
  }
539
- await this.sync(true)
540
- const fresh = getCachedTask(this.db, issueKey)
541
- if (!fresh) {
542
- providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
543
- }
544
- return fresh
616
+ return this.hydrateIssueByKey(issueKey)
545
617
  }
546
618
 
547
619
  async moveTask(idOrRef: string, column: string): Promise<Task> {
@@ -578,12 +650,7 @@ export class JiraProvider implements KanbanProvider {
578
650
  )
579
651
  }
580
652
  await this.client.transitionIssue(issueKey, match.id)
581
- await this.sync(true)
582
- const fresh = getCachedTask(this.db, issueKey)
583
- if (!fresh) {
584
- providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
585
- }
586
- return fresh
653
+ return this.hydrateIssueByKey(issueKey)
587
654
  }
588
655
 
589
656
  async deleteTask(_idOrRef: string): Promise<Task> {