github-issue-tower-defence-management 1.120.0 → 1.122.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -9
  3. package/bin/adapter/entry-points/cli/index.js +8 -14
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +0 -6
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +6 -12
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -10
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +2 -1
  12. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +23 -19
  14. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
  15. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
  16. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +1 -2
  17. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -1
  18. package/bin/adapter/repositories/LocalStorageCacheRepository.js +25 -0
  19. package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -1
  20. package/bin/adapter/repositories/ProcHostMetricsRepository.js +10 -5
  21. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
  22. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +119 -59
  23. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  24. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -3
  25. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  26. package/bin/domain/usecases/GetStoryObjectMapUseCase.js +1 -1
  27. package/bin/domain/usecases/GetStoryObjectMapUseCase.js.map +1 -1
  28. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -8
  29. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  30. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -2
  31. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  32. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
  33. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  34. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
  35. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  36. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +1 -1
  37. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
  38. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +1 -1
  39. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -1
  40. package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
  41. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  42. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +2 -2
  43. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/adapter/entry-points/cli/index.test.ts +0 -5
  46. package/src/adapter/entry-points/cli/index.ts +16 -17
  47. package/src/adapter/entry-points/cli/projectConfig.ts +0 -8
  48. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +4 -25
  49. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +4 -7
  50. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +2 -25
  51. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +4 -4
  52. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +11 -0
  53. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +3 -0
  54. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +29 -11
  55. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +30 -19
  56. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +0 -5
  57. package/src/adapter/entry-points/handlers/situationFileWriter.ts +0 -1
  58. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +0 -5
  59. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +1 -9
  60. package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +75 -0
  61. package/src/adapter/repositories/LocalStorageCacheRepository.ts +24 -0
  62. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +29 -8
  63. package/src/adapter/repositories/ProcHostMetricsRepository.ts +10 -6
  64. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +198 -47
  65. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +160 -81
  66. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +23 -3
  67. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +7 -12
  68. package/src/domain/usecases/GetStoryObjectMapUseCase.ts +1 -5
  69. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -24
  70. package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -17
  71. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -8
  72. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +35 -42
  73. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +1 -5
  74. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +32 -32
  75. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -5
  76. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +17 -4
  77. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +1 -4
  78. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -7
  79. package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +1 -5
  80. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -94
  81. package/src/domain/usecases/StartPreparationUseCase.ts +2 -5
  82. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -10
  83. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +11 -0
  84. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +3 -1
  85. package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -1
  86. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  87. package/types/adapter/entry-points/function/getStoryObjectMap.d.ts +1 -1
  88. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts +1 -1
  89. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  90. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +1 -0
  91. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -1
  92. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
  93. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
  94. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +0 -1
  95. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
  96. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +0 -1
  97. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -1
  98. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts +2 -0
  99. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -1
  100. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +1 -1
  101. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
  102. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +22 -7
  103. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  104. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -1
  105. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  106. package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts +0 -1
  107. package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts.map +1 -1
  108. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +0 -1
  109. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  110. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  111. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +0 -1
  112. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  113. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
  114. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  115. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
  116. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +0 -1
  117. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -1
  118. package/types/domain/usecases/StartPreparationUseCase.d.ts +0 -1
  119. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  120. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +4 -3
  121. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  122. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +1 -0
  123. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -1
@@ -104,6 +104,7 @@ export class HandleScheduledEventUseCaseHandler {
104
104
  dashboardDataDir?: string;
105
105
  workflowBlockerStoryName?: string;
106
106
  inTmuxDataOutputDir?: string;
107
+ newIssueRepo?: string;
107
108
  inTmuxConsoleBaseUrl?: string;
108
109
  inTmuxConsoleToken?: string;
109
110
  inTmuxProjectOrder?: string[];
@@ -182,8 +183,6 @@ export class HandleScheduledEventUseCaseHandler {
182
183
  allowedIssueAuthors: normalizeAllowedIssueAuthors(
183
184
  input.allowedIssueAuthors,
184
185
  ),
185
- allowIssueCacheMinutes:
186
- readmeConfig.allowIssueCacheMinutes ?? input.allowIssueCacheMinutes,
187
186
  claudeCodeOauthTokenListJsonPath:
188
187
  readmeConfig.claudeCodeOauthTokenListJsonPath ??
189
188
  input.claudeCodeOauthTokenListJsonPath,
@@ -302,6 +301,8 @@ export class HandleScheduledEventUseCaseHandler {
302
301
  restIssueRepository,
303
302
  graphqlProjectItemRepository,
304
303
  localStorageCacheRepository,
304
+ projectRepository,
305
+ systemDateRepository,
305
306
  ...githubRepositoryParams,
306
307
  );
307
308
  const setupTowerDefenceProjectUseCase = new SetupTowerDefenceProjectUseCase(
@@ -438,7 +439,6 @@ export class HandleScheduledEventUseCaseHandler {
438
439
  mergedInput.startPreparation?.maximumPreparingIssuesCount ?? null,
439
440
  utilizationPercentageThreshold:
440
441
  mergedInput.startPreparation?.utilizationPercentageThreshold ?? 90,
441
- allowIssueCacheMinutes: mergedInput.allowIssueCacheMinutes,
442
442
  thresholdForAutoReject: 3,
443
443
  },
444
444
  preparationProcessCheckCommand:
@@ -523,6 +523,7 @@ export class HandleScheduledEventUseCaseHandler {
523
523
  assigneeLogin: input.manager,
524
524
  org: input.org,
525
525
  repo: input.workingReport.repo,
526
+ newIssueRepo: mergedInput.newIssueRepo ?? undefined,
526
527
  project: result.project,
527
528
  issues: result.issues,
528
529
  now: inTmuxNow,
@@ -554,7 +555,6 @@ export class HandleScheduledEventUseCaseHandler {
554
555
  try {
555
556
  await cleanStaleTmuxSessions({
556
557
  project: result.project,
557
- allowCacheMinutes: mergedInput.allowIssueCacheMinutes,
558
558
  issueRepository,
559
559
  localCommandRunner: nodeLocalCommandRunner,
560
560
  now: inTmuxNow,
@@ -183,6 +183,17 @@ describe('writeInTmuxByHumanData', () => {
183
183
  });
184
184
  });
185
185
 
186
+ it('overrides the repo segment of the v4 new issue url when newIssueRepo is passed', () => {
187
+ writeInTmuxByHumanData({
188
+ ...baseParams(outDir),
189
+ newIssueRepo: 'other-repo',
190
+ });
191
+ expect(readJson(file('demo.v4.json'))).toMatchObject({
192
+ newIssueUrl:
193
+ 'https://github.com/demo-org/other-repo/issues/new?assignees=owner-login',
194
+ });
195
+ });
196
+
186
197
  it('writes index files listing configured projects whose per-project file exists', () => {
187
198
  fs.writeFileSync(file('xmile.json'), '[]\n');
188
199
  writeInTmuxByHumanData({
@@ -16,6 +16,7 @@ export type InTmuxByHumanDataWriterParams = {
16
16
  assigneeLogin: string | null | undefined;
17
17
  org: string;
18
18
  repo: string;
19
+ newIssueRepo?: string | null | undefined;
19
20
  project: Project;
20
21
  issues: Issue[];
21
22
  now: Date;
@@ -41,6 +42,7 @@ export const writeInTmuxByHumanData = (
41
42
  assigneeLogin,
42
43
  org,
43
44
  repo,
45
+ newIssueRepo,
44
46
  project,
45
47
  issues,
46
48
  now,
@@ -56,6 +58,7 @@ export const writeInTmuxByHumanData = (
56
58
  assigneeLogin,
57
59
  org,
58
60
  repo,
61
+ newIssueRepo: newIssueRepo ?? undefined,
59
62
  consoleBaseUrl: inTmuxConsoleBaseUrl ?? null,
60
63
  consoleToken: inTmuxConsoleToken ?? null,
61
64
  now,
@@ -53,17 +53,23 @@ describe('writeMachineStatus', () => {
53
53
  fs.rmSync(procDirectory, { recursive: true, force: true });
54
54
  });
55
55
 
56
- it('writes machine-status.json with mem, cpu, disk, load and cycle minutes', async () => {
56
+ it('writes machine-status.json with mem, cpu, disk, load and cycle minutes derived from lastFetchedAt', async () => {
57
57
  const { repository } = buildHostMetricsRepository(procDirectory);
58
58
  const cacheDir = path.join(dir, 'allIssues-PVT');
59
59
  fs.mkdirSync(cacheDir, { recursive: true });
60
- const newer = path.join(cacheDir, 'a.json');
61
- const older = path.join(cacheDir, 'b.json');
62
- fs.writeFileSync(older, '[]');
63
- fs.writeFileSync(newer, '[]');
64
- const base = Date.now();
65
- fs.utimesSync(older, new Date(base - 900000), new Date(base - 900000));
66
- fs.utimesSync(newer, new Date(base), new Date(base));
60
+ fs.writeFileSync(
61
+ path.join(cacheDir, 'latest.json'),
62
+ JSON.stringify({
63
+ lastFetchedAt: '2026-06-26T12:00:00.000Z',
64
+ lastFullFetchAt: '2026-06-26T11:00:00.000Z',
65
+ project: {},
66
+ issues: [],
67
+ }),
68
+ );
69
+ fs.writeFileSync(
70
+ path.join(dir, 'machine-status.json'),
71
+ JSON.stringify({ lastFetchedAt: '2026-06-26T11:45:00.000Z' }),
72
+ );
67
73
 
68
74
  await writeMachineStatus({
69
75
  dashboardDataDir: dir,
@@ -79,16 +85,25 @@ describe('writeMachineStatus', () => {
79
85
  diskPct: 90,
80
86
  load: [1.5, 0.8, 0.4],
81
87
  cycleMinutes: 15,
88
+ lastFetchedAt: '2026-06-26T12:00:00.000Z',
82
89
  capturedAt: '2026-06-26T12:00:00.000Z',
83
90
  };
84
91
  expect(written).toEqual(expected);
85
92
  });
86
93
 
87
- it('writes cycleMinutes null when fewer than two cache files exist', async () => {
94
+ it('writes cycleMinutes null when there is no previous machine-status', async () => {
88
95
  const { repository } = buildHostMetricsRepository(procDirectory);
89
96
  const cacheDir = path.join(dir, 'allIssues-PVT');
90
97
  fs.mkdirSync(cacheDir, { recursive: true });
91
- fs.writeFileSync(path.join(cacheDir, 'only.json'), '[]');
98
+ fs.writeFileSync(
99
+ path.join(cacheDir, 'latest.json'),
100
+ JSON.stringify({
101
+ lastFetchedAt: '2026-06-26T12:00:00.000Z',
102
+ lastFullFetchAt: '2026-06-26T11:00:00.000Z',
103
+ project: {},
104
+ issues: [],
105
+ }),
106
+ );
92
107
 
93
108
  await writeMachineStatus({
94
109
  dashboardDataDir: dir,
@@ -97,7 +112,10 @@ describe('writeMachineStatus', () => {
97
112
  });
98
113
 
99
114
  const written = readJson(path.join(dir, 'machine-status.json'));
100
- expect(written).toMatchObject({ cycleMinutes: null });
115
+ expect(written).toMatchObject({
116
+ cycleMinutes: null,
117
+ lastFetchedAt: '2026-06-26T12:00:00.000Z',
118
+ });
101
119
  });
102
120
 
103
121
  it('is a no-op when dashboardDataDir is unset', async () => {
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import {
4
4
  ProcHostMetricsRepository,
5
- cycleMinutesFromMtimes,
5
+ cycleMinutesFromFetchTimestamps,
6
6
  } from '../../repositories/ProcHostMetricsRepository';
7
7
 
8
8
  export type MachineStatusWriterParams = {
@@ -18,6 +18,7 @@ export type MachineStatusFile = {
18
18
  diskPct: number;
19
19
  load: [number, number, number];
20
20
  cycleMinutes: number | null;
21
+ lastFetchedAt: string | null;
21
22
  capturedAt: string;
22
23
  };
23
24
 
@@ -29,24 +30,26 @@ const writeJsonAtomic = (filePath: string, data: unknown): void => {
29
30
  fs.renameSync(tmpPath, filePath);
30
31
  };
31
32
 
32
- const cacheFileMtimesDescending = (allIssuesCacheDir: string): number[] => {
33
- let entries: string[];
33
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
34
+ typeof value === 'object' && value !== null;
35
+
36
+ const readLastFetchedAtFromJsonFile = (filePath: string): string | null => {
37
+ let raw: string;
34
38
  try {
35
- entries = fs.readdirSync(allIssuesCacheDir);
39
+ raw = fs.readFileSync(filePath, 'utf8');
36
40
  } catch {
37
- return [];
41
+ return null;
42
+ }
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (isRecord(parsed) && typeof parsed.lastFetchedAt === 'string') {
50
+ return parsed.lastFetchedAt;
38
51
  }
39
- return entries
40
- .filter((entry) => entry.endsWith('.json'))
41
- .map((entry) => {
42
- try {
43
- return fs.statSync(path.join(allIssuesCacheDir, entry)).mtimeMs / 1000;
44
- } catch {
45
- return null;
46
- }
47
- })
48
- .filter((value): value is number => value !== null)
49
- .sort((a, b) => b - a);
52
+ return null;
50
53
  };
51
54
 
52
55
  export const writeMachineStatus = async (
@@ -65,9 +68,16 @@ export const writeMachineStatus = async (
65
68
  const diskPct = hostMetricsRepository.readDiskUsedPercent();
66
69
  const load = hostMetricsRepository.readLoadAverages();
67
70
 
68
- const cycleMinutes = allIssuesCacheDir
69
- ? cycleMinutesFromMtimes(cacheFileMtimesDescending(allIssuesCacheDir))
71
+ const machineStatusPath = path.join(dashboardDataDir, 'machine-status.json');
72
+ const previousLastFetchedAt =
73
+ readLastFetchedAtFromJsonFile(machineStatusPath);
74
+ const currentLastFetchedAt = allIssuesCacheDir
75
+ ? readLastFetchedAtFromJsonFile(path.join(allIssuesCacheDir, 'latest.json'))
70
76
  : null;
77
+ const cycleMinutes = cycleMinutesFromFetchTimestamps(
78
+ previousLastFetchedAt,
79
+ currentLastFetchedAt,
80
+ );
71
81
 
72
82
  const file: MachineStatusFile = {
73
83
  memPct,
@@ -75,8 +85,9 @@ export const writeMachineStatus = async (
75
85
  diskPct,
76
86
  load: [load.oneMinute, load.fiveMinute, load.fifteenMinute],
77
87
  cycleMinutes,
88
+ lastFetchedAt: currentLastFetchedAt,
78
89
  capturedAt: (params.now ?? new Date()).toISOString(),
79
90
  };
80
91
 
81
- writeJsonAtomic(path.join(dashboardDataDir, 'machine-status.json'), file);
92
+ writeJsonAtomic(machineStatusPath, file);
82
93
  };
@@ -56,7 +56,6 @@ const baseParams = {
56
56
  config: {
57
57
  maximumPreparingIssuesCount: 6,
58
58
  utilizationPercentageThreshold: 90,
59
- allowIssueCacheMinutes: 5,
60
59
  thresholdForAutoReject: 3,
61
60
  },
62
61
  };
@@ -355,10 +354,6 @@ describe('writeSituationFile', () => {
355
354
  expect.any(String),
356
355
  expect.stringContaining('"utilizationPercentageThreshold":90'),
357
356
  );
358
- expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
359
- expect.any(String),
360
- expect.stringContaining('"allowIssueCacheMinutes":5'),
361
- );
362
357
  expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
363
358
  expect.any(String),
364
359
  expect.stringContaining('"thresholdForAutoReject":3'),
@@ -15,7 +15,6 @@ export type SituationFileParams = {
15
15
  config: {
16
16
  maximumPreparingIssuesCount: number | null;
17
17
  utilizationPercentageThreshold: number;
18
- allowIssueCacheMinutes: number;
19
18
  thresholdForAutoReject: number;
20
19
  };
21
20
  preparationProcessCheckCommand?: string | null;
@@ -7,8 +7,6 @@ import { cleanStaleTmuxSessions } from './staleTmuxSessionCleaner';
7
7
 
8
8
  const NOW = new Date('2026-06-26T00:00:00.000Z');
9
9
  const NOW_EPOCH_SECONDS = Math.floor(NOW.getTime() / 1000);
10
- const ALLOW_CACHE_MINUTES = 10;
11
-
12
10
  const makeProject = (): Project => ({
13
11
  id: 'project-1',
14
12
  url: 'https://github.com/users/user/projects/1',
@@ -91,7 +89,6 @@ describe('cleanStaleTmuxSessions', () => {
91
89
 
92
90
  await cleanStaleTmuxSessions({
93
91
  project: makeProject(),
94
- allowCacheMinutes: ALLOW_CACHE_MINUTES,
95
92
  issueRepository: createMockIssueRepository([
96
93
  makeIssue({ status: 'In Progress' }),
97
94
  ]),
@@ -124,7 +121,6 @@ describe('cleanStaleTmuxSessions', () => {
124
121
 
125
122
  await cleanStaleTmuxSessions({
126
123
  project: makeProject(),
127
- allowCacheMinutes: ALLOW_CACHE_MINUTES,
128
124
  issueRepository: createMockIssueRepository([
129
125
  makeIssue({ status: IN_TMUX_STATUS_NAME }),
130
126
  ]),
@@ -155,7 +151,6 @@ describe('cleanStaleTmuxSessions', () => {
155
151
 
156
152
  await cleanStaleTmuxSessions({
157
153
  project: makeProject(),
158
- allowCacheMinutes: ALLOW_CACHE_MINUTES,
159
154
  issueRepository: createMockIssueRepository([]),
160
155
  localCommandRunner: runner,
161
156
  now: NOW,
@@ -10,7 +10,6 @@ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRep
10
10
 
11
11
  export type CleanStaleTmuxSessionsParams = {
12
12
  project: Project;
13
- allowCacheMinutes: number;
14
13
  issueRepository: Pick<IssueRepository, 'getAllOpened'>;
15
14
  localCommandRunner: LocalCommandRunner;
16
15
  now: Date;
@@ -19,20 +18,13 @@ export type CleanStaleTmuxSessionsParams = {
19
18
  export const cleanStaleTmuxSessions = async (
20
19
  params: CleanStaleTmuxSessionsParams,
21
20
  ): Promise<void> => {
22
- const {
23
- project,
24
- allowCacheMinutes,
25
- issueRepository,
26
- localCommandRunner,
27
- now,
28
- } = params;
21
+ const { project, issueRepository, localCommandRunner, now } = params;
29
22
  const useCase = new StaleTmuxSessionKillUseCase(
30
23
  issueRepository,
31
24
  new NodeTmuxSessionRepository(localCommandRunner),
32
25
  );
33
26
  await useCase.run({
34
27
  project,
35
- allowCacheMinutes,
36
28
  excludedStatus: DEFAULT_EXCLUDED_STATUS,
37
29
  idleThresholdSeconds: DEFAULT_IDLE_THRESHOLD_SECONDS,
38
30
  now,
@@ -177,4 +177,79 @@ describe('LocalStorageCacheRepository', () => {
177
177
  expect(callOrder).toEqual(['write', 'rename']);
178
178
  });
179
179
  });
180
+
181
+ describe('getSingle', () => {
182
+ test('returns null when latest.json does not exist', async () => {
183
+ localStorageRepository.listFiles.mockReturnValue([]);
184
+
185
+ const result = await repository.getSingle('test-key');
186
+
187
+ expect(result).toBeNull();
188
+ expect(localStorageRepository.read).not.toHaveBeenCalled();
189
+ });
190
+
191
+ test('returns null when the file content is empty', async () => {
192
+ localStorageRepository.listFiles.mockReturnValue(['latest.json']);
193
+ localStorageRepository.read.mockReturnValue(null);
194
+
195
+ const result = await repository.getSingle('test-key');
196
+
197
+ expect(result).toBeNull();
198
+ });
199
+
200
+ test('returns null when the file content is invalid JSON', async () => {
201
+ localStorageRepository.listFiles.mockReturnValue(['latest.json']);
202
+ localStorageRepository.read.mockReturnValue('not-json');
203
+
204
+ const result = await repository.getSingle('test-key');
205
+
206
+ expect(result).toBeNull();
207
+ });
208
+
209
+ test('returns the parsed value from latest.json', async () => {
210
+ localStorageRepository.listFiles.mockReturnValue(['latest.json']);
211
+ localStorageRepository.read.mockReturnValue('{"lastFetchedAt":"x"}');
212
+
213
+ const result = await repository.getSingle('test-key');
214
+
215
+ expect(result).toEqual({ lastFetchedAt: 'x' });
216
+ expect(localStorageRepository.read).toHaveBeenCalledWith(
217
+ './tmp/cache/test-key/latest.json',
218
+ );
219
+ });
220
+ });
221
+
222
+ describe('setSingle', () => {
223
+ test('atomically writes a temp file then renames it to latest.json', async () => {
224
+ await repository.setSingle('test-key', { test: 'value' });
225
+
226
+ expect(localStorageRepository.mkdir).toHaveBeenCalledWith(
227
+ './tmp/cache/test-key',
228
+ );
229
+ const writeArgs = localStorageRepository.write.mock.calls[0];
230
+ const writtenPath = writeArgs[0];
231
+ const finalPath = './tmp/cache/test-key/latest.json';
232
+ expect(writtenPath.startsWith(`${finalPath}.`)).toBe(true);
233
+ expect(writtenPath.endsWith('.tmp')).toBe(true);
234
+ expect(writeArgs[1]).toBe('{"test":"value"}');
235
+ expect(localStorageRepository.rename).toHaveBeenCalledWith(
236
+ writtenPath,
237
+ finalPath,
238
+ );
239
+ });
240
+
241
+ test('writes to the temp file before renaming it into place', async () => {
242
+ const callOrder: string[] = [];
243
+ localStorageRepository.write.mockImplementation(() => {
244
+ callOrder.push('write');
245
+ });
246
+ localStorageRepository.rename.mockImplementation(() => {
247
+ callOrder.push('rename');
248
+ });
249
+
250
+ await repository.setSingle('ordering-key', { ordered: true });
251
+
252
+ expect(callOrder).toEqual(['write', 'rename']);
253
+ });
254
+ });
180
255
  });
@@ -51,4 +51,28 @@ export class LocalStorageCacheRepository {
51
51
  this.localStorageRepository.write(tmpPath, JSON.stringify(value));
52
52
  this.localStorageRepository.rename(tmpPath, finalPath);
53
53
  };
54
+ getSingle = async (key: string): Promise<unknown> => {
55
+ const dirPath = `${this.cachePath}/${key}`;
56
+ const fileName = 'latest.json';
57
+ if (!this.localStorageRepository.listFiles(dirPath).includes(fileName)) {
58
+ return null;
59
+ }
60
+ const valueStr = this.localStorageRepository.read(`${dirPath}/${fileName}`);
61
+ if (!valueStr) {
62
+ return null;
63
+ }
64
+ try {
65
+ return JSON.parse(valueStr);
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+ setSingle = async <T>(key: string, value: T): Promise<void> => {
71
+ const dirPath = `${this.cachePath}/${key}`;
72
+ this.localStorageRepository.mkdir(dirPath);
73
+ const finalPath = `${dirPath}/latest.json`;
74
+ const tmpPath = `${finalPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
75
+ this.localStorageRepository.write(tmpPath, JSON.stringify(value));
76
+ this.localStorageRepository.rename(tmpPath, finalPath);
77
+ };
54
78
  }
@@ -5,7 +5,7 @@ import {
5
5
  DiskBlocks,
6
6
  ProcHostMetricsRepository,
7
7
  cpuUsedPercentFromSamples,
8
- cycleMinutesFromMtimes,
8
+ cycleMinutesFromFetchTimestamps,
9
9
  parseCpuSample,
10
10
  parseDiskUsedPercent,
11
11
  parseLoadAverages,
@@ -88,18 +88,39 @@ describe('parseDiskUsedPercent', () => {
88
88
  });
89
89
  });
90
90
 
91
- describe('cycleMinutesFromMtimes', () => {
92
- it('rounds the gap between the two newest generations to whole minutes', () => {
93
- expect(cycleMinutesFromMtimes([1782469254.0, 1782468443.0])).toBe(14);
91
+ describe('cycleMinutesFromFetchTimestamps', () => {
92
+ it('rounds the gap between the previous and current fetch to whole minutes', () => {
93
+ expect(
94
+ cycleMinutesFromFetchTimestamps(
95
+ '2026-07-07T00:00:00.000Z',
96
+ '2026-07-07T00:14:00.000Z',
97
+ ),
98
+ ).toBe(14);
94
99
  });
95
100
 
96
101
  it('rounds a 1620 second gap to 27 minutes', () => {
97
- expect(cycleMinutesFromMtimes([1620.0, 0.0])).toBe(27);
102
+ expect(
103
+ cycleMinutesFromFetchTimestamps(
104
+ '2026-07-07T00:00:00.000Z',
105
+ '2026-07-07T00:27:00.000Z',
106
+ ),
107
+ ).toBe(27);
98
108
  });
99
109
 
100
- it('returns null when a second generation is missing', () => {
101
- expect(cycleMinutesFromMtimes([1000.0])).toBeNull();
102
- expect(cycleMinutesFromMtimes([])).toBeNull();
110
+ it('returns null when either timestamp is missing', () => {
111
+ expect(
112
+ cycleMinutesFromFetchTimestamps(null, '2026-07-07T00:00:00.000Z'),
113
+ ).toBeNull();
114
+ expect(
115
+ cycleMinutesFromFetchTimestamps('2026-07-07T00:00:00.000Z', null),
116
+ ).toBeNull();
117
+ expect(cycleMinutesFromFetchTimestamps(null, null)).toBeNull();
118
+ });
119
+
120
+ it('returns null when a timestamp is not parseable', () => {
121
+ expect(
122
+ cycleMinutesFromFetchTimestamps('not-a-date', '2026-07-07T00:00:00.000Z'),
123
+ ).toBeNull();
103
124
  });
104
125
  });
105
126
 
@@ -111,15 +111,19 @@ export const parseLoadAverages = (loadavgText: string): LoadAverages => {
111
111
  return { oneMinute, fiveMinute, fifteenMinute };
112
112
  };
113
113
 
114
- export const cycleMinutesFromMtimes = (
115
- mtimesDescendingSeconds: number[],
114
+ export const cycleMinutesFromFetchTimestamps = (
115
+ previousFetchedAtIso: string | null,
116
+ currentFetchedAtIso: string | null,
116
117
  ): number | null => {
117
- if (mtimesDescendingSeconds.length < 2) {
118
+ if (previousFetchedAtIso === null || currentFetchedAtIso === null) {
118
119
  return null;
119
120
  }
120
- return Math.round(
121
- (mtimesDescendingSeconds[0] - mtimesDescendingSeconds[1]) / 60,
122
- );
121
+ const previousMs = new Date(previousFetchedAtIso).getTime();
122
+ const currentMs = new Date(currentFetchedAtIso).getTime();
123
+ if (!Number.isFinite(previousMs) || !Number.isFinite(currentMs)) {
124
+ return null;
125
+ }
126
+ return Math.round((currentMs - previousMs) / 60000);
123
127
  };
124
128
 
125
129
  export class ProcHostMetricsRepository {