farming-code 2.2.16 → 2.2.18

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 (37) hide show
  1. package/README.md +2 -2
  2. package/README.zh_cn.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/backend/acp-runtime.js +221 -10
  5. package/backend/agent-manager.js +477 -54
  6. package/backend/agent-provider-session.js +15 -1
  7. package/backend/codex-rollout-follower.js +83 -0
  8. package/backend/codex-session-archive.js +12 -1
  9. package/backend/codex-session-history.js +94 -0
  10. package/backend/config-manager.js +3 -3
  11. package/backend/farming-app-cli.js +8 -0
  12. package/backend/native-pty-host-client.js +59 -2
  13. package/backend/provider-adapters.js +117 -36
  14. package/backend/provider-session-service.js +138 -34
  15. package/backend/server.js +5 -1
  16. package/backend/storage-layout.js +5 -0
  17. package/backend/usage-history-client.js +305 -0
  18. package/backend/usage-history-scanner.generated.js +2250 -0
  19. package/backend/usage-history-scanner.ts +2718 -0
  20. package/backend/usage-history-smoke.js +64 -0
  21. package/backend/usage-history-worker.js +34 -0
  22. package/backend/usage-monitor.js +188 -588
  23. package/backend/vendor/usage-parsers/CC_STATISTICS_LICENSE +21 -0
  24. package/backend/vendor/usage-parsers/CODEXBAR_LICENSE +21 -0
  25. package/backend/vendor/usage-parsers/VENDOR.json +45 -0
  26. package/dist/assets/App-DTAQilnX.js +210 -0
  27. package/dist/assets/code-dark-5esj0bRu.css +1 -0
  28. package/dist/assets/{index-iKBvPjWS.js → index-fp8VhOes.js} +2 -2
  29. package/dist/assets/main-DSO3i_Gs.css +1 -0
  30. package/dist/index.html +1 -1
  31. package/frontend/skins/crt/app.js +9 -3
  32. package/package.json +12 -8
  33. package/shared/backend-connection-status.d.ts +25 -0
  34. package/shared/backend-connection-status.js +40 -0
  35. package/dist/assets/App-y6p2yRar.js +0 -206
  36. package/dist/assets/code-dark-DKBGxoPX.css +0 -1
  37. package/dist/assets/main-CdkLOXYl.css +0 -1
@@ -1,12 +1,16 @@
1
+ const fs = require('fs');
1
2
  const path = require('path');
2
3
  const { findAgentSession } = require('./agent-session-history');
3
- const { listCodexSessions } = require('./codex-session-history');
4
- const { isLinkedWorktreeOf } = require('./git-worktree-info');
4
+ const {
5
+ codexSessionDateKeys,
6
+ listCodexSessionIdentities,
7
+ } = require('./codex-session-history');
5
8
  const { mainPageAgentSessionKey } = require('./main-page-session');
6
9
  const { isTemporaryProviderSessionId } = require('./provider-session-id');
7
10
 
8
11
  const CODEX_RESOLVE_COOLDOWN_MS = 1000;
9
- const CODEX_MATCH_GRACE_MS = 30 * 1000;
12
+ const CODEX_MATCH_WINDOW_MS = 30 * 1000;
13
+ const CODEX_STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 12000];
10
14
  const TITLE_RESOLVE_COOLDOWN_MS = 30 * 1000;
11
15
 
12
16
  function normalizePath(value) {
@@ -16,6 +20,16 @@ function normalizePath(value) {
16
20
  return trimmed.replace(/[\\/]+$/, '');
17
21
  }
18
22
 
23
+ function canonicalPath(value) {
24
+ const normalized = normalizePath(value);
25
+ if (!normalized) return '';
26
+ try {
27
+ return normalizePath(fs.realpathSync.native(normalized));
28
+ } catch {
29
+ return normalized;
30
+ }
31
+ }
32
+
19
33
  function timestampMs(value) {
20
34
  const parsed = Date.parse(value || '');
21
35
  return Number.isFinite(parsed) ? parsed : 0;
@@ -26,10 +40,14 @@ class ProviderSessionService {
26
40
  this.agents = options.agents || new Map();
27
41
  this.getProviderHomes = options.getProviderHomes || (() => undefined);
28
42
  this.commit = options.commit || (() => {});
29
- this.listCodexSessions = options.listCodexSessions || listCodexSessions;
43
+ this.listCodexSessionIdentities = options.listCodexSessionIdentities || listCodexSessionIdentities;
30
44
  this.findAgentSession = options.findAgentSession || findAgentSession;
31
- this.isLinkedWorktreeOf = options.isLinkedWorktreeOf || isLinkedWorktreeOf;
45
+ this.codexStartupRetryDelaysMs = Array.isArray(options.codexStartupRetryDelaysMs)
46
+ ? options.codexStartupRetryDelaysMs
47
+ : CODEX_STARTUP_RETRY_DELAYS_MS;
32
48
  this.resolutions = new Map();
49
+ this.codexIdentityScans = new Map();
50
+ this.codexStartupRetries = new Map();
33
51
  }
34
52
 
35
53
  activate(agentId) {
@@ -38,6 +56,7 @@ class ProviderSessionService {
38
56
 
39
57
  if (agent.providerSessionProvider === 'codex' && agent.providerSessionTemporary === true) {
40
58
  this.observe(agentId, { force: true });
59
+ this.scheduleTemporaryCodexStartupRetry(agentId);
41
60
  return;
42
61
  }
43
62
 
@@ -54,10 +73,71 @@ class ProviderSessionService {
54
73
  stop(agentId) {
55
74
  this.resolutions.delete(`codex:${agentId}`);
56
75
  this.resolutions.delete(`title:${agentId}`);
76
+ const startupRetry = this.codexStartupRetries.get(agentId);
77
+ if (startupRetry?.timer) clearTimeout(startupRetry.timer);
78
+ this.codexStartupRetries.delete(agentId);
57
79
  }
58
80
 
59
81
  dispose() {
82
+ for (const startupRetry of this.codexStartupRetries.values()) {
83
+ if (startupRetry?.timer) clearTimeout(startupRetry.timer);
84
+ }
60
85
  this.resolutions.clear();
86
+ this.codexIdentityScans.clear();
87
+ this.codexStartupRetries.clear();
88
+ }
89
+
90
+ scheduleTemporaryCodexStartupRetry(agentId) {
91
+ if (this.codexStartupRetries.has(agentId)) return;
92
+ const agent = this.agents.get(agentId);
93
+ const retry = {
94
+ attempt: 0,
95
+ timer: null,
96
+ deadlineAt: (Number(agent?.startedAt) || Date.now()) + CODEX_MATCH_WINDOW_MS,
97
+ };
98
+ this.codexStartupRetries.set(agentId, retry);
99
+
100
+ const scheduleNext = () => {
101
+ if (this.codexStartupRetries.get(agentId) !== retry) return;
102
+ const agent = this.agents.get(agentId);
103
+ if (
104
+ !agent
105
+ || agent.providerSessionProvider !== 'codex'
106
+ || agent.providerSessionTemporary !== true
107
+ ) {
108
+ this.stop(agentId);
109
+ return;
110
+ }
111
+ const delayMs = Number(this.codexStartupRetryDelaysMs[retry.attempt]);
112
+ if (!Number.isFinite(delayMs) || delayMs < 0) {
113
+ this.codexStartupRetries.delete(agentId);
114
+ return;
115
+ }
116
+ if (Date.now() + delayMs > retry.deadlineAt) {
117
+ this.codexStartupRetries.delete(agentId);
118
+ return;
119
+ }
120
+ retry.attempt += 1;
121
+ retry.timer = setTimeout(async () => {
122
+ retry.timer = null;
123
+ if (
124
+ this.codexStartupRetries.get(agentId) !== retry
125
+ || Date.now() > retry.deadlineAt
126
+ ) {
127
+ if (this.codexStartupRetries.get(agentId) === retry) {
128
+ this.codexStartupRetries.delete(agentId);
129
+ }
130
+ return;
131
+ }
132
+ const resolved = await this.resolveTemporaryCodex(agentId, { force: true });
133
+ if (this.codexStartupRetries.get(agentId) !== retry) return;
134
+ if (resolved) return;
135
+ scheduleNext();
136
+ }, delayMs);
137
+ retry.timer.unref?.();
138
+ };
139
+
140
+ scheduleNext();
61
141
  }
62
142
 
63
143
  bindConfirmed(agentId, provider, sessionId) {
@@ -97,6 +177,14 @@ class ProviderSessionService {
97
177
  this.resolutions.delete(`codex:${agentId}`);
98
178
  return Promise.resolve(false);
99
179
  }
180
+ const startedAt = Number(agent.startedAt) || 0;
181
+ if (
182
+ options.force !== true
183
+ && startedAt
184
+ && Date.now() > startedAt + CODEX_MATCH_WINDOW_MS
185
+ ) {
186
+ return Promise.resolve(false);
187
+ }
100
188
 
101
189
  return this.runResolution(
102
190
  'codex',
@@ -167,15 +255,30 @@ class ProviderSessionService {
167
255
  }
168
256
 
169
257
  async findTemporaryCodexSession(agent) {
170
- const sessions = await this.listCodexSessions({
171
- codexHome: agent.providerHomePath || undefined,
172
- limit: 100,
173
- scanLimit: 1000,
174
- });
258
+ const startedAt = Number(agent.startedAt) || 0;
259
+ const codexHome = agent.providerHomePath || '';
260
+ const scanKey = [
261
+ codexHome,
262
+ ...codexSessionDateKeys(startedAt, CODEX_MATCH_WINDOW_MS),
263
+ ].join('\0');
264
+ let scan = this.codexIdentityScans.get(scanKey);
265
+ if (!scan) {
266
+ scan = Promise.resolve(this.listCodexSessionIdentities({
267
+ codexHome: codexHome || undefined,
268
+ startedAt,
269
+ windowMs: CODEX_MATCH_WINDOW_MS,
270
+ })).finally(() => {
271
+ if (this.codexIdentityScans.get(scanKey) === scan) {
272
+ this.codexIdentityScans.delete(scanKey);
273
+ }
274
+ });
275
+ this.codexIdentityScans.set(scanKey, scan);
276
+ }
277
+ const sessions = await scan;
175
278
  const workspace = normalizePath(
176
- agent?.gitWorktree?.workspace || agent?.projectWorkspace || agent?.cwd || ''
279
+ agent?.projectWorkspace || agent?.cwd || agent?.gitWorktree?.workspace || ''
177
280
  );
178
- const startedAt = Number(agent.startedAt) || 0;
281
+ if (!workspace) return null;
179
282
  const homeId = String(agent.providerHomeId || 'default').trim() || 'default';
180
283
  const claimedSessionIds = new Set([...this.agents.values()]
181
284
  .filter(candidate => candidate?.id !== agent.id
@@ -189,35 +292,36 @@ class ProviderSessionService {
189
292
  if (!session?.id || claimedSessionIds.has(session.id)) return false;
190
293
  const sessionWorkspace = normalizePath(session.workspace || session.cwd);
191
294
  if (workspace && !sessionWorkspace) return false;
192
- const sessionTime = timestampMs(session.createdAt || session.updatedAt);
193
- if (!sessionTime || !startedAt) return true;
194
- return sessionTime >= startedAt - CODEX_MATCH_GRACE_MS;
195
- })
196
- .sort((a, b) => {
197
- const aTime = timestampMs(a.createdAt || a.updatedAt);
198
- const bTime = timestampMs(b.createdAt || b.updatedAt);
199
- const aDistance = startedAt && aTime ? Math.abs(aTime - startedAt) : Number.MAX_SAFE_INTEGER;
200
- const bDistance = startedAt && bTime ? Math.abs(bTime - startedAt) : Number.MAX_SAFE_INTEGER;
201
- if (aDistance !== bDistance) return aDistance - bDistance;
202
- return bTime - aTime;
295
+ const sessionTime = timestampMs(session.createdAt);
296
+ if (!sessionTime || !startedAt) return false;
297
+ return Math.abs(sessionTime - startedAt) <= CODEX_MATCH_WINDOW_MS;
203
298
  });
204
299
 
205
- const exact = candidates.find(session => (
206
- !workspace || workspace === normalizePath(session.workspace || session.cwd)
300
+ const exact = candidates.filter(session => (
301
+ workspace === normalizePath(session.workspace || session.cwd)
207
302
  ));
208
- if (exact) return exact;
209
- if (!workspace) return candidates[0] || null;
210
-
211
- for (const session of candidates.slice(0, 12)) {
212
- const sessionWorkspace = normalizePath(session.workspace || session.cwd);
213
- if (!sessionWorkspace) continue;
214
- if (await this.isLinkedWorktreeOf(workspace, sessionWorkspace)) return session;
215
- }
303
+ if (exact.length === 1) return exact[0];
304
+ if (exact.length > 1) return null;
305
+ const canonicalWorkspace = canonicalPath(workspace);
306
+ const canonical = candidates.filter(session => (
307
+ canonicalWorkspace === canonicalPath(session.workspace || session.cwd)
308
+ ));
309
+ if (canonical.length === 1) return canonical[0];
216
310
  return null;
217
311
  }
218
312
 
219
313
  confirm(agentId, { provider, sessionId, source, title, workspace }) {
220
- const previousSessionId = this.agents.get(agentId)?.providerSessionId || '';
314
+ const current = this.agents.get(agentId);
315
+ const homeId = String(current?.providerHomeId || 'default').trim() || 'default';
316
+ const claimedByAnotherAgent = [...this.agents.values()].some(candidate => (
317
+ candidate?.id !== agentId
318
+ && candidate?.providerSessionProvider === provider
319
+ && candidate?.providerSessionId === sessionId
320
+ && candidate?.providerSessionTemporary !== true
321
+ && (String(candidate.providerHomeId || 'default').trim() || 'default') === homeId
322
+ ));
323
+ if (claimedByAnotherAgent) return false;
324
+ const previousSessionId = current?.providerSessionId || '';
221
325
  const agent = this.bindConfirmed(agentId, provider, sessionId);
222
326
  if (!agent) return false;
223
327
  const providerSessionTitle = String(title || '').trim().slice(0, 160);
package/backend/server.js CHANGED
@@ -122,7 +122,11 @@ const updateService = new FarmingUpdateService({
122
122
  packagedRuntime: Boolean(process.pkg || process.env.FARMING_PACKAGED_RUNTIME === '1'),
123
123
  getUpdateUrl: () => configManager.getSettings().updateUrl || '',
124
124
  });
125
- const usageMonitor = new UsageMonitor({ agentManager, getProviderHomes: configuredProviderHomes });
125
+ const usageMonitor = new UsageMonitor({
126
+ agentManager,
127
+ configDir: configManager.farmingDir,
128
+ getProviderHomes: configuredProviderHomes,
129
+ });
126
130
  const codexContextWindowReader = new CodexContextWindowReader();
127
131
  const usageSummaryCache = new AsyncCache(() => usageMonitor.getUsageSummary(), {
128
132
  ttlMs: 15_000,
@@ -41,6 +41,10 @@ function reviewSessionsFile(configDir) {
41
41
  return path.join(historyDir(configDir), 'review-sessions.json');
42
42
  }
43
43
 
44
+ function usageHistoryCacheFile(configDir) {
45
+ return path.join(historyDir(configDir), 'usage-history-v2.sqlite3');
46
+ }
47
+
44
48
  function sessionIndexFile(configDir) {
45
49
  return path.join(sessionsDir(configDir), 'index.json');
46
50
  }
@@ -137,4 +141,5 @@ module.exports = {
137
141
  themeSettingsFile,
138
142
  updateLogFile,
139
143
  updateStateFile,
144
+ usageHistoryCacheFile,
140
145
  };
@@ -0,0 +1,305 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const { Worker } = require('worker_threads');
4
+ const { usageHistoryCacheFile } = require('./storage-layout');
5
+
6
+ const DEFAULT_TIMEOUT_MS = 120_000;
7
+ const DEFAULT_RETENTION_DAYS = 52 * 7;
8
+ const DEFAULT_RECENT_RAW_MS = 24 * 60 * 60 * 1000;
9
+ const DEFAULT_SCAN_BUDGET_MS = 5_000;
10
+ const BACKGROUND_SCAN_DELAY_MS = 100;
11
+ const RESULT_REUSE_MS = 2_000;
12
+ const SOURCE_WORKER_FILE = 'usage-history-worker.js';
13
+ const PACKAGED_WORKER_FILE = 'usage-history-worker.pkg.js';
14
+ let sharedWorkerSession = null;
15
+ let nextWorkerRequestId = 1;
16
+
17
+ function resolveWorkerFile() {
18
+ if (!process.pkg && process.env.FARMING_PACKAGED_RUNTIME !== '1') {
19
+ return SOURCE_WORKER_FILE;
20
+ }
21
+ const packaged = path.join(__dirname, PACKAGED_WORKER_FILE);
22
+ return fs.existsSync(packaged) ? PACKAGED_WORKER_FILE : SOURCE_WORKER_FILE;
23
+ }
24
+
25
+ function runUsageWorker(request, options = {}) {
26
+ if (options.WorkerClass) {
27
+ return runOneShotUsageWorker(request, options);
28
+ }
29
+ const workerFile = path.join(__dirname, resolveWorkerFile());
30
+ if (!sharedWorkerSession || sharedWorkerSession.workerFile !== workerFile) {
31
+ sharedWorkerSession?.terminate();
32
+ sharedWorkerSession = createSharedWorkerSession(workerFile, options.WorkerClass || Worker);
33
+ }
34
+ return sharedWorkerSession.run(request, options.timeoutMs || DEFAULT_TIMEOUT_MS);
35
+ }
36
+
37
+ function runOneShotUsageWorker(request, options = {}) {
38
+ return new Promise((resolve, reject) => {
39
+ const worker = new (options.WorkerClass || Worker)(
40
+ path.join(__dirname, resolveWorkerFile()),
41
+ { workerData: { request } },
42
+ );
43
+ let settled = false;
44
+ const finish = (error, value) => {
45
+ if (settled) return;
46
+ settled = true;
47
+ clearTimeout(timeout);
48
+ worker.terminate().catch(() => {});
49
+ if (error) reject(error);
50
+ else resolve(value);
51
+ };
52
+ const timeout = setTimeout(() => {
53
+ const error = new Error(
54
+ `TypeScript usage scanner exceeded ${options.timeoutMs || DEFAULT_TIMEOUT_MS}ms`,
55
+ );
56
+ error.code = 'ETIMEDOUT';
57
+ finish(error);
58
+ }, options.timeoutMs || DEFAULT_TIMEOUT_MS);
59
+ timeout.unref?.();
60
+ worker.once('error', finish);
61
+ worker.once('exit', (code) => {
62
+ if (!settled) {
63
+ const error = new Error(
64
+ code === 0
65
+ ? 'TypeScript usage scanner exited without returning a result'
66
+ : `TypeScript usage scanner exited with code ${code}`,
67
+ );
68
+ error.code = 'EUSAGEWORKER';
69
+ finish(error);
70
+ }
71
+ });
72
+ worker.once('message', (message) => {
73
+ if (message?.error) {
74
+ const error = new Error(message.error.message || 'Usage scanner failed');
75
+ error.code = message.error.code || 'EUSAGE';
76
+ error.stack = message.error.stack || error.stack;
77
+ finish(error);
78
+ return;
79
+ }
80
+ finish(null, message?.result);
81
+ });
82
+ });
83
+ }
84
+
85
+ function createSharedWorkerSession(workerFile, WorkerClass) {
86
+ const worker = new WorkerClass(workerFile);
87
+ const pending = new Map();
88
+ const failAll = (error) => {
89
+ for (const request of pending.values()) {
90
+ clearTimeout(request.timeout);
91
+ request.reject(error);
92
+ }
93
+ pending.clear();
94
+ if (sharedWorkerSession?.worker === worker) sharedWorkerSession = null;
95
+ };
96
+ worker.on('message', (message) => {
97
+ const request = pending.get(message?.requestId);
98
+ if (!request) return;
99
+ pending.delete(message.requestId);
100
+ clearTimeout(request.timeout);
101
+ if (message?.error) {
102
+ const error = new Error(message.error.message || 'Usage scanner failed');
103
+ error.code = message.error.code || 'EUSAGE';
104
+ error.stack = message.error.stack || error.stack;
105
+ request.reject(error);
106
+ } else {
107
+ request.resolve(message?.result);
108
+ }
109
+ if (pending.size === 0) worker.unref?.();
110
+ });
111
+ worker.once('error', failAll);
112
+ worker.once('exit', (code) => {
113
+ if (pending.size > 0) {
114
+ const error = new Error(
115
+ code === 0
116
+ ? 'TypeScript usage scanner exited before returning all results'
117
+ : `TypeScript usage scanner exited with code ${code}`,
118
+ );
119
+ error.code = 'EUSAGEWORKER';
120
+ failAll(error);
121
+ } else if (sharedWorkerSession?.worker === worker) {
122
+ sharedWorkerSession = null;
123
+ }
124
+ });
125
+ worker.unref?.();
126
+ return {
127
+ worker,
128
+ workerFile,
129
+ run(request, timeoutMs) {
130
+ return new Promise((resolve, reject) => {
131
+ const requestId = nextWorkerRequestId++;
132
+ const timeout = setTimeout(() => {
133
+ if (!pending.delete(requestId)) return;
134
+ const error = new Error(`TypeScript usage scanner exceeded ${timeoutMs}ms`);
135
+ error.code = 'ETIMEDOUT';
136
+ reject(error);
137
+ worker.terminate().catch(() => {});
138
+ if (sharedWorkerSession?.worker === worker) sharedWorkerSession = null;
139
+ }, timeoutMs);
140
+ timeout.unref?.();
141
+ pending.set(requestId, { resolve, reject, timeout });
142
+ worker.ref?.();
143
+ worker.postMessage({ requestId, request });
144
+ });
145
+ },
146
+ terminate() {
147
+ const error = new Error('Usage scanner worker was replaced');
148
+ error.code = 'EUSAGEWORKER';
149
+ failAll(error);
150
+ worker.terminate().catch(() => {});
151
+ },
152
+ };
153
+ }
154
+
155
+ class UsageHistoryClient {
156
+ constructor(options = {}) {
157
+ this.configDir = options.configDir;
158
+ this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
159
+ this.runner = options.runner || runUsageWorker;
160
+ this.pending = null;
161
+ this.pendingKey = '';
162
+ this.cached = null;
163
+ this.cachedAt = 0;
164
+ this.cacheKey = '';
165
+ this.backgroundTimer = null;
166
+ this.backgroundGeneration = 0;
167
+ this.backgroundDelayMs = options.backgroundDelayMs || BACKGROUND_SCAN_DELAY_MS;
168
+ this.backgroundErrorDelayMs = options.backgroundErrorDelayMs || 5_000;
169
+ this.backgroundErrorRetries = 0;
170
+ this.backgroundStalls = 0;
171
+ this.backgroundProgressSignature = '';
172
+ }
173
+
174
+ invoke(request) {
175
+ return this.runner(request, { timeoutMs: this.timeoutMs });
176
+ }
177
+
178
+ storeResult(result, cacheKey) {
179
+ if (cacheKey !== this.cacheKey) return;
180
+ this.cached = result;
181
+ this.cachedAt = Number.isFinite(result?.sampledAt)
182
+ ? result.sampledAt
183
+ : Date.now();
184
+ }
185
+
186
+ scheduleBackgroundScan(request, cacheKey, result) {
187
+ if (result?.cache?.scan_complete !== false || cacheKey !== this.cacheKey) {
188
+ this.backgroundErrorRetries = 0;
189
+ this.backgroundStalls = 0;
190
+ this.backgroundProgressSignature = '';
191
+ return;
192
+ }
193
+ if (this.backgroundTimer) return;
194
+ const hasErrors = Number(result?.cache?.errors) > 0;
195
+ this.backgroundErrorRetries = hasErrors ? this.backgroundErrorRetries + 1 : 0;
196
+ const progressSignature = [
197
+ result?.cache?.pending_directories,
198
+ result?.cache?.pending_files,
199
+ result?.cache?.discovered_files,
200
+ result?.cache?.committed_bytes,
201
+ ].join(':');
202
+ if (Number(result?.cache?.enumerated_entries) > 0) {
203
+ this.backgroundProgressSignature = progressSignature;
204
+ this.backgroundStalls = 0;
205
+ } else if (progressSignature === this.backgroundProgressSignature) {
206
+ this.backgroundStalls += 1;
207
+ } else {
208
+ this.backgroundProgressSignature = progressSignature;
209
+ this.backgroundStalls = 0;
210
+ }
211
+ if (this.backgroundErrorRetries >= 3 || this.backgroundStalls >= 3) return;
212
+ const generation = this.backgroundGeneration;
213
+ const retryDelayMs = hasErrors
214
+ ? this.backgroundErrorDelayMs
215
+ : this.backgroundDelayMs;
216
+ this.backgroundTimer = setTimeout(() => {
217
+ this.backgroundTimer = null;
218
+ if (generation !== this.backgroundGeneration || cacheKey !== this.cacheKey) return;
219
+ const backgroundRequest = { ...request, nowMs: Date.now() };
220
+ const pending = this.invoke(backgroundRequest).then((nextResult) => {
221
+ this.storeResult(nextResult, cacheKey);
222
+ this.scheduleBackgroundScan(backgroundRequest, cacheKey, nextResult);
223
+ return nextResult;
224
+ }).catch(() => {
225
+ // Keep the last usable snapshot. A later normal request retries.
226
+ }).finally(() => {
227
+ if (this.pending === pending) {
228
+ this.pending = null;
229
+ this.pendingKey = '';
230
+ }
231
+ });
232
+ this.pending = pending;
233
+ this.pendingKey = cacheKey;
234
+ }, retryDelayMs);
235
+ this.backgroundTimer.unref?.();
236
+ }
237
+
238
+ collect(options = {}) {
239
+ const now = options.now ?? Date.now();
240
+ const retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS;
241
+ const roots = {
242
+ codex: Array.from(new Set(options.codexRoots || [])).sort(),
243
+ claude: Array.from(new Set(options.claudeRoots || [])).sort(),
244
+ };
245
+ const recentRawMs = options.recentRawMs ?? DEFAULT_RECENT_RAW_MS;
246
+ const cacheKey = JSON.stringify({ roots, retentionDays, recentRawMs });
247
+ if (this.cacheKey && this.cacheKey !== cacheKey) {
248
+ this.backgroundGeneration += 1;
249
+ if (this.backgroundTimer) clearTimeout(this.backgroundTimer);
250
+ this.backgroundTimer = null;
251
+ this.backgroundErrorRetries = 0;
252
+ this.backgroundStalls = 0;
253
+ this.backgroundProgressSignature = '';
254
+ }
255
+ if (
256
+ options.fresh !== true
257
+ && this.cached
258
+ && this.cacheKey === cacheKey
259
+ && (
260
+ now - this.cachedAt <= RESULT_REUSE_MS
261
+ || this.backgroundTimer
262
+ || (this.pending && this.pendingKey === cacheKey)
263
+ )
264
+ ) {
265
+ return Promise.resolve(this.cached);
266
+ }
267
+ if (this.pending && this.pendingKey === cacheKey) return this.pending;
268
+ if (this.pending) return this.pending.then(() => this.collect(options));
269
+ this.cacheKey = cacheKey;
270
+ this.backgroundErrorRetries = 0;
271
+ this.backgroundStalls = 0;
272
+ this.backgroundProgressSignature = '';
273
+ const request = {
274
+ cacheFile: usageHistoryCacheFile(this.configDir),
275
+ legacyCacheFile: options.legacyCacheFile,
276
+ nowMs: now,
277
+ retentionDays,
278
+ recentRawMs,
279
+ scanBudgetMs: options.scanBudgetMs ?? DEFAULT_SCAN_BUDGET_MS,
280
+ roots,
281
+ };
282
+ const pending = this.invoke(request).then((result) => {
283
+ this.storeResult(result, cacheKey);
284
+ this.scheduleBackgroundScan(request, cacheKey, result);
285
+ return result;
286
+ }).finally(() => {
287
+ if (this.pending === pending) {
288
+ this.pending = null;
289
+ this.pendingKey = '';
290
+ }
291
+ });
292
+ this.pending = pending;
293
+ this.pendingKey = cacheKey;
294
+ return pending;
295
+ }
296
+ }
297
+
298
+ module.exports = {
299
+ UsageHistoryClient,
300
+ DEFAULT_RECENT_RAW_MS,
301
+ DEFAULT_RETENTION_DAYS,
302
+ DEFAULT_SCAN_BUDGET_MS,
303
+ resolveWorkerFile,
304
+ runUsageWorker,
305
+ };