github-issue-tower-defence-management 1.126.1 → 1.126.3

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.
Files changed (26) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +49 -24
  3. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  4. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +50 -36
  5. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  6. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +33 -2
  7. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  8. package/package.json +4 -4
  9. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +5 -2
  10. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +183 -18
  11. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +59 -26
  12. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +148 -0
  13. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +62 -45
  14. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  15. package/src/domain/entities/LiveSessionOutputActivity.ts +1 -0
  16. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +70 -0
  17. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +37 -1
  18. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +1 -11
  19. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  20. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  21. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  22. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  23. package/types/domain/entities/LiveSessionOutputActivity.d.ts +1 -0
  24. package/types/domain/entities/LiveSessionOutputActivity.d.ts.map +1 -1
  25. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +1 -0
  26. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
@@ -25,6 +25,14 @@ describe('FileSystemSessionOutputActivityRepository', () => {
25
25
  },
26
26
  });
27
27
 
28
+ const assistantEntryWithoutTimestamp = (): object => ({
29
+ type: 'assistant',
30
+ message: {
31
+ role: 'assistant',
32
+ content: [{ type: 'text', text: 'no timestamp on this entry' }],
33
+ },
34
+ });
35
+
28
36
  const userEntry = (timestamp: string): object => ({
29
37
  type: 'user',
30
38
  timestamp,
@@ -37,6 +45,51 @@ describe('FileSystemSessionOutputActivityRepository', () => {
37
45
  toolUseResult: { stdout: 'done', stderr: '' },
38
46
  });
39
47
 
48
+ const systemEntry = (timestamp: string): object => ({
49
+ type: 'system',
50
+ timestamp,
51
+ content: 'Compacting conversation history',
52
+ });
53
+
54
+ const assistantEntryWithToolUse = (
55
+ timestamp: string,
56
+ toolUseId: string,
57
+ ): object => ({
58
+ type: 'assistant',
59
+ timestamp,
60
+ message: {
61
+ role: 'assistant',
62
+ stop_reason: 'tool_use',
63
+ content: [
64
+ { type: 'text', text: 'running a command' },
65
+ {
66
+ type: 'tool_use',
67
+ id: toolUseId,
68
+ name: 'Bash',
69
+ input: { command: 'sleep 590' },
70
+ },
71
+ ],
72
+ },
73
+ });
74
+
75
+ const toolResultUserEntry = (
76
+ timestamp: string,
77
+ toolUseId: string,
78
+ ): object => ({
79
+ type: 'user',
80
+ timestamp,
81
+ message: {
82
+ role: 'user',
83
+ content: [
84
+ {
85
+ type: 'tool_result',
86
+ tool_use_id: toolUseId,
87
+ content: 'done',
88
+ },
89
+ ],
90
+ },
91
+ });
92
+
40
93
  const untimestampedEntry = (): object => ({
41
94
  type: 'summary',
42
95
  summary: 'no timestamp on this entry',
@@ -52,11 +105,56 @@ describe('FileSystemSessionOutputActivityRepository', () => {
52
105
  return filePath;
53
106
  };
54
107
 
55
- it('returns the latest entry timestamp of any type as the last activity epoch', async () => {
108
+ it('returns the old last-assistant timestamp when recent system and tool entries follow it', async () => {
109
+ const transcriptPath = writeTranscript('thrashing.jsonl', [
110
+ assistantEntry('2026-06-27T01:00:00.000Z'),
111
+ systemEntry('2026-06-27T09:30:00.000Z'),
112
+ toolResultEntry('2026-06-27T09:45:00.000Z'),
113
+ systemEntry('2026-06-27T10:00:00.000Z'),
114
+ ]);
115
+ const repository = new FileSystemSessionOutputActivityRepository();
116
+
117
+ const result = await repository.listSessionOutputActivities(
118
+ new Map([['thrashing', transcriptPath]]),
119
+ );
120
+
121
+ expect(result).toEqual([
122
+ {
123
+ sessionName: 'thrashing',
124
+ lastOutputEpochSeconds: Math.floor(
125
+ Date.parse('2026-06-27T01:00:00.000Z') / 1000,
126
+ ),
127
+ hasInProgressToolCall: false,
128
+ },
129
+ ]);
130
+ });
131
+
132
+ it('returns the recent timestamp when the latest assistant entry is recent', async () => {
133
+ const transcriptPath = writeTranscript('working.jsonl', [
134
+ userEntry('2026-06-27T09:00:00.000Z'),
135
+ assistantEntry('2026-06-27T09:55:00.000Z'),
136
+ ]);
137
+ const repository = new FileSystemSessionOutputActivityRepository();
138
+
139
+ const result = await repository.listSessionOutputActivities(
140
+ new Map([['working', transcriptPath]]),
141
+ );
142
+
143
+ expect(result).toEqual([
144
+ {
145
+ sessionName: 'working',
146
+ lastOutputEpochSeconds: Math.floor(
147
+ Date.parse('2026-06-27T09:55:00.000Z') / 1000,
148
+ ),
149
+ hasInProgressToolCall: false,
150
+ },
151
+ ]);
152
+ });
153
+
154
+ it('ignores a later user entry and returns the last assistant timestamp', async () => {
56
155
  const transcriptPath = writeTranscript('workbench.jsonl', [
57
156
  assistantEntry('2026-06-27T10:00:00.000Z'),
58
- userEntry('2026-06-27T10:30:00.000Z'),
59
- assistantEntry('2026-06-27T10:05:00.000Z'),
157
+ userEntry('2026-06-27T11:00:00.000Z'),
60
158
  ]);
61
159
  const repository = new FileSystemSessionOutputActivityRepository();
62
160
 
@@ -68,16 +166,41 @@ describe('FileSystemSessionOutputActivityRepository', () => {
68
166
  {
69
167
  sessionName: 'workbench',
70
168
  lastOutputEpochSeconds: Math.floor(
71
- Date.parse('2026-06-27T10:30:00.000Z') / 1000,
169
+ Date.parse('2026-06-27T10:00:00.000Z') / 1000,
72
170
  ),
171
+ hasInProgressToolCall: false,
73
172
  },
74
173
  ]);
75
174
  });
76
175
 
77
- it('advances the last activity time when a later user entry follows an assistant entry', async () => {
176
+ it('ignores a later tool_result entry and returns the last assistant timestamp', async () => {
78
177
  const transcriptPath = writeTranscript('workbench.jsonl', [
79
178
  assistantEntry('2026-06-27T10:00:00.000Z'),
80
- userEntry('2026-06-27T11:00:00.000Z'),
179
+ toolResultEntry('2026-06-27T10:45:00.000Z'),
180
+ ]);
181
+ const repository = new FileSystemSessionOutputActivityRepository();
182
+
183
+ const result = await repository.listSessionOutputActivities(
184
+ new Map([['workbench', transcriptPath]]),
185
+ );
186
+
187
+ expect(result).toEqual([
188
+ {
189
+ sessionName: 'workbench',
190
+ lastOutputEpochSeconds: Math.floor(
191
+ Date.parse('2026-06-27T10:00:00.000Z') / 1000,
192
+ ),
193
+ hasInProgressToolCall: false,
194
+ },
195
+ ]);
196
+ });
197
+
198
+ it('returns the last assistant entry timestamp when several entry types interleave', async () => {
199
+ const transcriptPath = writeTranscript('workbench.jsonl', [
200
+ assistantEntry('2026-06-27T10:00:00.000Z'),
201
+ userEntry('2026-06-27T10:10:00.000Z'),
202
+ assistantEntry('2026-06-27T10:05:00.000Z'),
203
+ systemEntry('2026-06-27T10:40:00.000Z'),
81
204
  ]);
82
205
  const repository = new FileSystemSessionOutputActivityRepository();
83
206
 
@@ -89,16 +212,17 @@ describe('FileSystemSessionOutputActivityRepository', () => {
89
212
  {
90
213
  sessionName: 'workbench',
91
214
  lastOutputEpochSeconds: Math.floor(
92
- Date.parse('2026-06-27T11:00:00.000Z') / 1000,
215
+ Date.parse('2026-06-27T10:05:00.000Z') / 1000,
93
216
  ),
217
+ hasInProgressToolCall: false,
94
218
  },
95
219
  ]);
96
220
  });
97
221
 
98
- it('advances the last activity time when a tool_result entry is the latest entry', async () => {
222
+ it('falls back to the last assistant entry that has a parseable timestamp', async () => {
99
223
  const transcriptPath = writeTranscript('workbench.jsonl', [
100
224
  assistantEntry('2026-06-27T10:00:00.000Z'),
101
- toolResultEntry('2026-06-27T10:45:00.000Z'),
225
+ assistantEntryWithoutTimestamp(),
102
226
  ]);
103
227
  const repository = new FileSystemSessionOutputActivityRepository();
104
228
 
@@ -110,8 +234,9 @@ describe('FileSystemSessionOutputActivityRepository', () => {
110
234
  {
111
235
  sessionName: 'workbench',
112
236
  lastOutputEpochSeconds: Math.floor(
113
- Date.parse('2026-06-27T10:45:00.000Z') / 1000,
237
+ Date.parse('2026-06-27T10:00:00.000Z') / 1000,
114
238
  ),
239
+ hasInProgressToolCall: false,
115
240
  },
116
241
  ]);
117
242
  });
@@ -135,13 +260,29 @@ describe('FileSystemSessionOutputActivityRepository', () => {
135
260
  lastOutputEpochSeconds: Math.floor(
136
261
  Date.parse('2026-06-27T10:00:00.000Z') / 1000,
137
262
  ),
263
+ hasInProgressToolCall: false,
138
264
  },
139
265
  ]);
140
266
  });
141
267
 
142
- it('resolves a transcript whose only entry is a non-assistant entry', async () => {
268
+ it('omits a transcript whose only entries are non-assistant entries', async () => {
143
269
  const transcriptPath = writeTranscript('workbench.jsonl', [
144
270
  userEntry('2026-06-27T10:00:00.000Z'),
271
+ toolResultEntry('2026-06-27T10:05:00.000Z'),
272
+ systemEntry('2026-06-27T10:10:00.000Z'),
273
+ ]);
274
+ const repository = new FileSystemSessionOutputActivityRepository();
275
+
276
+ const result = await repository.listSessionOutputActivities(
277
+ new Map([['workbench', transcriptPath]]),
278
+ );
279
+
280
+ expect(result).toEqual([]);
281
+ });
282
+
283
+ it('omits sessions whose transcript has no parseable timestamp', async () => {
284
+ const transcriptPath = writeTranscript('workbench.jsonl', [
285
+ untimestampedEntry(),
145
286
  ]);
146
287
  const repository = new FileSystemSessionOutputActivityRepository();
147
288
 
@@ -149,27 +290,51 @@ describe('FileSystemSessionOutputActivityRepository', () => {
149
290
  new Map([['workbench', transcriptPath]]),
150
291
  );
151
292
 
293
+ expect(result).toEqual([]);
294
+ });
295
+
296
+ it('flags a session as waiting on a running tool when the last assistant tool_use has no matching tool_result', async () => {
297
+ const transcriptPath = writeTranscript('busy.jsonl', [
298
+ assistantEntry('2026-06-27T09:00:00.000Z'),
299
+ assistantEntryWithToolUse('2026-06-27T09:01:00.000Z', 'toolu_running'),
300
+ ]);
301
+ const repository = new FileSystemSessionOutputActivityRepository();
302
+
303
+ const result = await repository.listSessionOutputActivities(
304
+ new Map([['busy', transcriptPath]]),
305
+ );
306
+
152
307
  expect(result).toEqual([
153
308
  {
154
- sessionName: 'workbench',
309
+ sessionName: 'busy',
155
310
  lastOutputEpochSeconds: Math.floor(
156
- Date.parse('2026-06-27T10:00:00.000Z') / 1000,
311
+ Date.parse('2026-06-27T09:01:00.000Z') / 1000,
157
312
  ),
313
+ hasInProgressToolCall: true,
158
314
  },
159
315
  ]);
160
316
  });
161
317
 
162
- it('omits sessions whose transcript has no parseable timestamp', async () => {
163
- const transcriptPath = writeTranscript('workbench.jsonl', [
164
- untimestampedEntry(),
318
+ it('does not flag a session once the matching tool_result for its last tool_use is appended', async () => {
319
+ const transcriptPath = writeTranscript('completed.jsonl', [
320
+ assistantEntryWithToolUse('2026-06-27T09:01:00.000Z', 'toolu_done'),
321
+ toolResultUserEntry('2026-06-27T09:05:00.000Z', 'toolu_done'),
165
322
  ]);
166
323
  const repository = new FileSystemSessionOutputActivityRepository();
167
324
 
168
325
  const result = await repository.listSessionOutputActivities(
169
- new Map([['workbench', transcriptPath]]),
326
+ new Map([['completed', transcriptPath]]),
170
327
  );
171
328
 
172
- expect(result).toEqual([]);
329
+ expect(result).toEqual([
330
+ {
331
+ sessionName: 'completed',
332
+ lastOutputEpochSeconds: Math.floor(
333
+ Date.parse('2026-06-27T09:01:00.000Z') / 1000,
334
+ ),
335
+ hasInProgressToolCall: false,
336
+ },
337
+ ]);
173
338
  });
174
339
 
175
340
  it('returns an empty list when no sessions are requested', async () => {
@@ -21,41 +21,54 @@ const parseEpochMilliseconds = (timestamp: string | null): number | null => {
21
21
  return Number.isNaN(parsed) ? null : parsed;
22
22
  };
23
23
 
24
- /**
25
- * Reads the last main-session activity time for each live session from its
26
- * already-resolved transcript path. Idle time is computed from the timestamp of
27
- * the latest entry of any kind (assistant text, owner replies, tool results, or
28
- * any other entry type) rather than from the transcript file modification time.
29
- * Because a session that is actively running tool calls keeps appending entries
30
- * such as `user` and `tool_result` even while it emits no assistant text, every
31
- * entry with a parseable timestamp counts as activity, so a working session is
32
- * not mistaken for a silent one.
33
- */
24
+ const readContentBlocks = (
25
+ message: Record<string, unknown>,
26
+ ): Record<string, unknown>[] => {
27
+ const content = message.content;
28
+ if (!Array.isArray(content)) {
29
+ return [];
30
+ }
31
+ return content.filter(isRecord);
32
+ };
33
+
34
+ type TranscriptActivity = {
35
+ lastAssistantOutputEpochSeconds: number | null;
36
+ hasInProgressToolCall: boolean;
37
+ };
38
+
34
39
  export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
35
40
  listSessionOutputActivities = async (
36
41
  transcriptPathBySessionName: Map<string, string>,
37
42
  ): Promise<LiveSessionOutputActivity[]> => {
38
43
  const activities: LiveSessionOutputActivity[] = [];
39
44
  for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
40
- const lastOutputEpochSeconds =
41
- this.readLastActivityEpochSeconds(transcriptPath);
42
- if (lastOutputEpochSeconds !== null) {
43
- activities.push({ sessionName, lastOutputEpochSeconds });
45
+ const { lastAssistantOutputEpochSeconds, hasInProgressToolCall } =
46
+ this.readTranscriptActivity(transcriptPath);
47
+ if (lastAssistantOutputEpochSeconds !== null) {
48
+ activities.push({
49
+ sessionName,
50
+ lastOutputEpochSeconds: lastAssistantOutputEpochSeconds,
51
+ hasInProgressToolCall,
52
+ });
44
53
  }
45
54
  }
46
55
  return activities;
47
56
  };
48
57
 
49
- private readLastActivityEpochSeconds = (
58
+ private readTranscriptActivity = (
50
59
  transcriptPath: string,
51
- ): number | null => {
60
+ ): TranscriptActivity => {
52
61
  let content: string;
53
62
  try {
54
63
  content = fs.readFileSync(transcriptPath, 'utf8');
55
64
  } catch {
56
- return null;
65
+ return {
66
+ lastAssistantOutputEpochSeconds: null,
67
+ hasInProgressToolCall: false,
68
+ };
57
69
  }
58
- let lastActivityEpochMs: number | null = null;
70
+ let lastAssistantOutputEpochMs: number | null = null;
71
+ const pendingToolUseIds = new Set<string>();
59
72
  for (const line of content.split('\n')) {
60
73
  const trimmed = line.trim();
61
74
  if (trimmed.length === 0) {
@@ -70,17 +83,37 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
70
83
  if (!isRecord(parsed)) {
71
84
  continue;
72
85
  }
73
- const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
74
- if (epochMs === null) {
86
+ const message = parsed.message;
87
+ if (readString(parsed, 'type') === 'assistant') {
88
+ const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
89
+ if (epochMs !== null) {
90
+ lastAssistantOutputEpochMs = epochMs;
91
+ }
92
+ }
93
+ if (!isRecord(message)) {
75
94
  continue;
76
95
  }
77
- if (lastActivityEpochMs === null || epochMs > lastActivityEpochMs) {
78
- lastActivityEpochMs = epochMs;
96
+ for (const block of readContentBlocks(message)) {
97
+ const blockType = readString(block, 'type');
98
+ if (blockType === 'tool_use') {
99
+ const toolUseId = readString(block, 'id');
100
+ if (toolUseId !== null) {
101
+ pendingToolUseIds.add(toolUseId);
102
+ }
103
+ } else if (blockType === 'tool_result') {
104
+ const toolUseId = readString(block, 'tool_use_id');
105
+ if (toolUseId !== null) {
106
+ pendingToolUseIds.delete(toolUseId);
107
+ }
108
+ }
79
109
  }
80
110
  }
81
- if (lastActivityEpochMs === null) {
82
- return null;
83
- }
84
- return Math.floor(lastActivityEpochMs / 1000);
111
+ return {
112
+ lastAssistantOutputEpochSeconds:
113
+ lastAssistantOutputEpochMs === null
114
+ ? null
115
+ : Math.floor(lastAssistantOutputEpochMs / 1000),
116
+ hasInProgressToolCall: pendingToolUseIds.size > 0,
117
+ };
85
118
  };
86
119
  }
@@ -838,6 +838,154 @@ describe('GraphqlProjectItemRepository', () => {
838
838
 
839
839
  expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
840
840
  });
841
+
842
+ it('retries the full fetch exactly once when the last page reports fewer accumulated items than totalCount and returns the consistent retry result', async () => {
843
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
844
+ const repository = new GraphqlProjectItemRepository(
845
+ new LocalStorageRepository(),
846
+ 'dummy-token',
847
+ );
848
+ mockPost
849
+ .mockReturnValueOnce(
850
+ makeLightPageResponse(
851
+ false,
852
+ 'cursor-1',
853
+ [
854
+ {
855
+ id: 'PVTI_1',
856
+ updatedAt: '2026-07-07T10:00:00Z',
857
+ content: { url: 'https://github.com/o/r/issues/1', number: 1 },
858
+ },
859
+ ],
860
+ 2,
861
+ ),
862
+ )
863
+ .mockReturnValueOnce(
864
+ makeLightPageResponse(
865
+ false,
866
+ 'cursor-1',
867
+ [
868
+ {
869
+ id: 'PVTI_1',
870
+ updatedAt: '2026-07-07T10:00:00Z',
871
+ content: { url: 'https://github.com/o/r/issues/1', number: 1 },
872
+ },
873
+ {
874
+ id: 'PVTI_2',
875
+ updatedAt: '2026-07-07T11:00:00Z',
876
+ content: { url: 'https://github.com/o/r/issues/2', number: 2 },
877
+ },
878
+ ],
879
+ 2,
880
+ ),
881
+ );
882
+
883
+ const result = await repository.fetchProjectItemsLight(
884
+ 'test-project-id',
885
+ 'updated:>=2026-07-07',
886
+ );
887
+
888
+ expect(mockPost).toHaveBeenCalledTimes(2);
889
+ expect(result.map((item) => item.id)).toEqual(['PVTI_1', 'PVTI_2']);
890
+ expect(warnSpy).toHaveBeenCalledWith(
891
+ expect.stringContaining(
892
+ 'page 1 has 1 nodes with hasNextPage=false but only 1/2 items accumulated',
893
+ ),
894
+ );
895
+ warnSpy.mockRestore();
896
+ });
897
+
898
+ it('warns and returns the accumulated items without throwing when the retry is still inconsistent', async () => {
899
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
900
+ const repository = new GraphqlProjectItemRepository(
901
+ new LocalStorageRepository(),
902
+ 'dummy-token',
903
+ );
904
+ const inconsistentPage = () =>
905
+ makeLightPageResponse(
906
+ false,
907
+ 'cursor-1',
908
+ [
909
+ {
910
+ id: 'PVTI_1',
911
+ updatedAt: '2026-07-07T10:00:00Z',
912
+ content: { url: 'https://github.com/o/r/issues/1', number: 1 },
913
+ },
914
+ ],
915
+ 2,
916
+ );
917
+ mockPost
918
+ .mockReturnValueOnce(inconsistentPage())
919
+ .mockReturnValueOnce(inconsistentPage());
920
+
921
+ const result = await repository.fetchProjectItemsLight(
922
+ 'test-project-id',
923
+ 'updated:>=2026-07-07',
924
+ );
925
+
926
+ expect(mockPost).toHaveBeenCalledTimes(2);
927
+ expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
928
+ expect(warnSpy).toHaveBeenCalledWith(
929
+ expect.stringContaining(
930
+ 'page 1 has 1 nodes with hasNextPage=false but only 1/2 items accumulated',
931
+ ),
932
+ );
933
+ warnSpy.mockRestore();
934
+ });
935
+
936
+ it('retries exactly once when the accumulated count exceeds totalCount after the loop and returns the consistent retry result', async () => {
937
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
938
+ const repository = new GraphqlProjectItemRepository(
939
+ new LocalStorageRepository(),
940
+ 'dummy-token',
941
+ );
942
+ mockPost
943
+ .mockReturnValueOnce(
944
+ makeLightPageResponse(
945
+ false,
946
+ 'cursor-1',
947
+ [
948
+ {
949
+ id: 'PVTI_1',
950
+ updatedAt: '2026-07-07T10:00:00Z',
951
+ content: { url: 'https://github.com/o/r/issues/1', number: 1 },
952
+ },
953
+ {
954
+ id: 'PVTI_2',
955
+ updatedAt: '2026-07-07T11:00:00Z',
956
+ content: { url: 'https://github.com/o/r/issues/2', number: 2 },
957
+ },
958
+ ],
959
+ 1,
960
+ ),
961
+ )
962
+ .mockReturnValueOnce(
963
+ makeLightPageResponse(
964
+ false,
965
+ 'cursor-1',
966
+ [
967
+ {
968
+ id: 'PVTI_1',
969
+ updatedAt: '2026-07-07T10:00:00Z',
970
+ content: { url: 'https://github.com/o/r/issues/1', number: 1 },
971
+ },
972
+ ],
973
+ 1,
974
+ ),
975
+ );
976
+
977
+ const result = await repository.fetchProjectItemsLight(
978
+ 'test-project-id',
979
+ 'updated:>=2026-07-07',
980
+ );
981
+
982
+ expect(mockPost).toHaveBeenCalledTimes(2);
983
+ expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
984
+ expect(warnSpy).toHaveBeenCalledWith(
985
+ expect.stringContaining('expected 1 items but accumulated 2'),
986
+ );
987
+ warnSpy.mockRestore();
988
+ });
841
989
  });
842
990
 
843
991
  describe('fetchProjectItemsByIds', () => {
@@ -719,59 +719,76 @@ query GetProjectItemsLight($projectId: ID!, $after: String, $first: Int!, $query
719
719
  }
720
720
  return rawData.node.items;
721
721
  };
722
- const lightItems: ProjectItemLight[] = [];
723
- let after: string | null = null;
724
- let hasNextPage = true;
725
- let totalCount = 0;
726
- let cumulativeRawNodes = 0;
727
- let pageIndex = 0;
728
- while (hasNextPage) {
729
- if (after !== null) {
730
- await new Promise((resolve) =>
731
- setTimeout(resolve, PAGINATION_DELAY_MS),
722
+ const fetchAllLightPages = async (): Promise<{
723
+ lightItems: ProjectItemLight[];
724
+ inconsistencyMessage: string | null;
725
+ }> => {
726
+ const lightItems: ProjectItemLight[] = [];
727
+ let after: string | null = null;
728
+ let hasNextPage = true;
729
+ let totalCount = 0;
730
+ let cumulativeRawNodes = 0;
731
+ let pageIndex = 0;
732
+ let inconsistencyMessage: string | null = null;
733
+ while (hasNextPage) {
734
+ if (after !== null) {
735
+ await new Promise((resolve) =>
736
+ setTimeout(resolve, PAGINATION_DELAY_MS),
737
+ );
738
+ }
739
+ const items = await callGraphql(after);
740
+ const pageNodes = items.nodes;
741
+ const pageInfo = items.pageInfo;
742
+ totalCount = items.totalCount;
743
+ cumulativeRawNodes += pageNodes.length;
744
+ pageIndex++;
745
+ console.log(
746
+ `fetchProjectItemsLight: page ${pageIndex}, nodes: ${pageNodes.length}, cumulative: ${cumulativeRawNodes}/${totalCount}`,
732
747
  );
748
+ pageNodes.forEach((node) => {
749
+ if (!node || !node.content || !node.content.url) {
750
+ return;
751
+ }
752
+ lightItems.push({
753
+ id: node.id,
754
+ updatedAt: node.updatedAt,
755
+ url: node.content.url,
756
+ number: node.content.number,
757
+ });
758
+ });
759
+ if (
760
+ pageNodes.length > 0 &&
761
+ !pageInfo.hasNextPage &&
762
+ cumulativeRawNodes < totalCount
763
+ ) {
764
+ inconsistencyMessage = `fetchProjectItemsLight: page ${pageIndex} has ${pageNodes.length} nodes with hasNextPage=false but only ${cumulativeRawNodes}/${totalCount} items accumulated`;
765
+ }
766
+ hasNextPage = pageInfo.hasNextPage;
767
+ after = pageInfo.endCursor;
733
768
  }
734
- const items = await callGraphql(after);
735
- const pageNodes = items.nodes;
736
- const pageInfo = items.pageInfo;
737
- totalCount = items.totalCount;
738
- cumulativeRawNodes += pageNodes.length;
739
- pageIndex++;
740
769
  console.log(
741
- `fetchProjectItemsLight: page ${pageIndex}, nodes: ${pageNodes.length}, cumulative: ${cumulativeRawNodes}/${totalCount}`,
770
+ `fetchProjectItemsLight: completed, totalCount: ${totalCount}, cumulativeRawNodes: ${cumulativeRawNodes}, items: ${lightItems.length}`,
742
771
  );
743
- pageNodes.forEach((node) => {
744
- if (!node || !node.content || !node.content.url) {
745
- return;
746
- }
747
- lightItems.push({
748
- id: node.id,
749
- updatedAt: node.updatedAt,
750
- url: node.content.url,
751
- number: node.content.number,
752
- });
753
- });
754
- if (
755
- pageNodes.length > 0 &&
756
- !pageInfo.hasNextPage &&
757
- cumulativeRawNodes < totalCount
758
- ) {
759
- throw new Error(
760
- `fetchProjectItemsLight: page ${pageIndex} has ${pageNodes.length} nodes with hasNextPage=false but only ${cumulativeRawNodes}/${totalCount} items accumulated`,
761
- );
772
+ if (inconsistencyMessage === null && cumulativeRawNodes !== totalCount) {
773
+ inconsistencyMessage = `fetchProjectItemsLight: expected ${totalCount} items but accumulated ${cumulativeRawNodes}`;
762
774
  }
763
- hasNextPage = pageInfo.hasNextPage;
764
- after = pageInfo.endCursor;
775
+ return { lightItems, inconsistencyMessage };
776
+ };
777
+ const firstAttempt = await fetchAllLightPages();
778
+ if (firstAttempt.inconsistencyMessage === null) {
779
+ return firstAttempt.lightItems;
765
780
  }
766
- console.log(
767
- `fetchProjectItemsLight: completed, totalCount: ${totalCount}, cumulativeRawNodes: ${cumulativeRawNodes}, items: ${lightItems.length}`,
781
+ console.warn(
782
+ `${firstAttempt.inconsistencyMessage}, retrying full fetch once`,
768
783
  );
769
- if (cumulativeRawNodes !== totalCount) {
770
- throw new Error(
771
- `fetchProjectItemsLight: expected ${totalCount} items but accumulated ${cumulativeRawNodes}`,
772
- );
784
+ const retryAttempt = await fetchAllLightPages();
785
+ if (retryAttempt.inconsistencyMessage === null) {
786
+ return retryAttempt.lightItems;
773
787
  }
774
- return lightItems;
788
+ console.warn(
789
+ `${retryAttempt.inconsistencyMessage}, continuing with accumulated items after retry`,
790
+ );
791
+ return retryAttempt.lightItems;
775
792
  };
776
793
  fetchProjectItemsByIds = async (ids: string[]): Promise<ProjectItem[]> => {
777
794
  if (ids.length === 0) {