claude-dev-env 8.38.0 → 8.38.2

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.
package/bin/install.mjs CHANGED
@@ -1319,7 +1319,9 @@ function managedPackageSourceRoots() {
1319
1319
  * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
1320
1320
  * @returns {boolean} True when the command references a managed script.
1321
1321
  */
1322
- export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
1322
+ export function commandReferencesManagedHook(
1323
+ commandString, managedHookRelativePaths, sharedSettingsRealPath = null,
1324
+ ) {
1323
1325
  if (typeof commandString !== 'string') return false;
1324
1326
  const normalizedCommand = commandString.replace(/\\/g, '/');
1325
1327
  if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
@@ -1329,6 +1331,51 @@ export function commandReferencesManagedHook(commandString, managedHookRelativeP
1329
1331
  if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) {
1330
1332
  return true;
1331
1333
  }
1334
+ if (
1335
+ sharedSettingsRealPath
1336
+ && commandRunsHookOfRootSharingSettings(normalizedCommand, relativePath, sharedSettingsRealPath)
1337
+ ) {
1338
+ return true;
1339
+ }
1340
+ }
1341
+ return false;
1342
+ }
1343
+
1344
+ /**
1345
+ * Report whether a command runs `<root>/hooks/<relative>` for a root whose
1346
+ * settings.json resolves to the given settings file.
1347
+ *
1348
+ * Two install roots can share one settings.json through a symlink. Each install
1349
+ * writes its own hooks directory into that one file, so the merge treats the
1350
+ * hooks of every root that shares the file as managed and keeps one entry per
1351
+ * script.
1352
+ *
1353
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
1354
+ * @param {string} relativePath The managed script path under hooks/.
1355
+ * @param {string} sharedSettingsRealPath The resolved path of the settings file being merged.
1356
+ * @returns {boolean} True when the command runs that script under a sharing root.
1357
+ */
1358
+ function commandRunsHookOfRootSharingSettings(normalizedCommand, relativePath, sharedSettingsRealPath) {
1359
+ const commandArgumentBoundary = /[\s'";]/;
1360
+ const hookPathTail = `/${MANAGED_HOOKS_DIRECTORY_NAME}/${relativePath}`;
1361
+ const sharedSettingsKey = comparisonKeyForPath(sharedSettingsRealPath);
1362
+ let tailStart = normalizedCommand.indexOf(hookPathTail);
1363
+ while (tailStart !== -1) {
1364
+ const characterAfterPath = normalizedCommand[tailStart + hookPathTail.length];
1365
+ if (characterAfterPath === undefined || commandArgumentBoundary.test(characterAfterPath)) {
1366
+ let rootStart = tailStart;
1367
+ while (rootStart > 0 && !commandArgumentBoundary.test(normalizedCommand[rootStart - 1])) {
1368
+ rootStart--;
1369
+ }
1370
+ const rootPath = normalizedCommand.slice(rootStart, tailStart);
1371
+ const rootSettingsRealPath = rootPath
1372
+ ? realPathOrNull(join(rootPath, SETTINGS_FILE_NAME))
1373
+ : null;
1374
+ if (rootSettingsRealPath && comparisonKeyForPath(rootSettingsRealPath) === sharedSettingsKey) {
1375
+ return true;
1376
+ }
1377
+ }
1378
+ tailStart = normalizedCommand.indexOf(hookPathTail, tailStart + 1);
1332
1379
  }
1333
1380
  return false;
1334
1381
  }
@@ -1399,14 +1446,17 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
1399
1446
  * @param {object} settings The parsed settings.json object (mutated in place).
1400
1447
  * @param {string} eventType The lifecycle event whose groups are pruned.
1401
1448
  * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
1449
+ * @param {string|null} sharedSettingsRealPath Resolved path of the settings file, or null.
1402
1450
  * @returns {void}
1403
1451
  */
1404
- function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
1452
+ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths, sharedSettingsRealPath) {
1405
1453
  const existingGroups = settings.hooks[eventType];
1406
1454
  if (!Array.isArray(existingGroups)) return;
1407
1455
  settings.hooks[eventType] = retainedMatcherGroups(
1408
1456
  existingGroups,
1409
- commandString => commandReferencesManagedHook(commandString, managedHookRelativePaths),
1457
+ commandString => commandReferencesManagedHook(
1458
+ commandString, managedHookRelativePaths, sharedSettingsRealPath,
1459
+ ),
1410
1460
  ).keptGroups;
1411
1461
  }
1412
1462
 
@@ -1457,9 +1507,14 @@ function startEventFromHookGroupList(settings, eventType) {
1457
1507
  * @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to
1458
1508
  * (the installer's `~/.claude` root; home is its parent directory).
1459
1509
  * @param {string} pythonCommand Interpreter command that replaces python3.
1510
+ * @param {string|null} [sharedSettingsRealPath] Resolved path of the settings
1511
+ * file being merged. Hooks of every root whose settings.json resolves to it
1512
+ * count as managed, so roots sharing one file keep one entry per script.
1460
1513
  * @returns {number} Count of matcher groups merged.
1461
1514
  */
1462
- export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
1515
+ export function mergeHooksIntoSettings(
1516
+ settings, hooksConfig, pluginRootDir, pythonCommand, sharedSettingsRealPath = null,
1517
+ ) {
1463
1518
  const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
1464
1519
  const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
1465
1520
  const encodedPluginHooksPath = Buffer.from(`${pluginRootForward}/hooks`, 'utf8').toString('base64');
@@ -1467,7 +1522,7 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
1467
1522
  let groupCount = 0;
1468
1523
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
1469
1524
  startEventFromHookGroupList(settings, eventType);
1470
- pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
1525
+ pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths, sharedSettingsRealPath);
1471
1526
  for (const sourceGroup of matcherGroups) {
1472
1527
  const rewrittenHooks = sourceGroup.hooks.map(hook => {
1473
1528
  let command = hook.command;
@@ -1492,7 +1547,9 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
1492
1547
  if (existingIndex >= 0) {
1493
1548
  const existing = settings.hooks[eventType][existingIndex];
1494
1549
  const userHooks = (groupHookEntries(existing) || []).filter(
1495
- hook => !commandReferencesManagedHook(hook?.command, managedHookRelativePaths)
1550
+ hook => !commandReferencesManagedHook(
1551
+ hook?.command, managedHookRelativePaths, sharedSettingsRealPath,
1552
+ )
1496
1553
  );
1497
1554
  settings.hooks[eventType][existingIndex] = {
1498
1555
  ...existing,
@@ -2079,7 +2136,9 @@ function mergeHooksAtPath(
2079
2136
  pruneManagedHooksFromSettings(settings, new Set());
2080
2137
  }
2081
2138
  const hooksConfig = selectHooksConfig(sourceHooksConfig);
2082
- const groupCount = mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand);
2139
+ const groupCount = mergeHooksIntoSettings(
2140
+ settings, hooksConfig, pluginRootDir, pythonCommand, realPathOrNull(settingsPath),
2141
+ );
2083
2142
  writeFileSync(settingsPath, JSON.stringify(settings, null, indentation) + '\n');
2084
2143
  return groupCount;
2085
2144
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Two install roots that share one settings.json through a symlink keep one
3
+ * hook entry per managed script, whatever order the roots install in.
4
+ */
5
+
6
+ import { test } from 'node:test';
7
+ import { strict as assert } from 'node:assert';
8
+ import { join } from 'node:path';
9
+ import {
10
+ mkdtempSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ rmSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { managedHookScriptRelativePaths, mergeHooksIntoSettings } from './install.mjs';
21
+
22
+ const INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
23
+ const PACKAGE_HOOKS_JSON_PATH = fileURLToPath(new URL('../hooks/hooks.json', import.meta.url));
24
+
25
+ function installEnvironment(homeDirectory) {
26
+ return {
27
+ ...process.env,
28
+ HOME: homeDirectory,
29
+ USERPROFILE: homeDirectory,
30
+ CLAUDE_CONFIG_DIR: '',
31
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
32
+ CDE_INSTALL_PSTACK: '0',
33
+ CDE_INSTALL_USAGE_WRAPUP: '0',
34
+ };
35
+ }
36
+
37
+ function runCoreInstall(homeDirectory, extraArguments) {
38
+ const installRun = spawnSync(
39
+ process.execPath,
40
+ [INSTALLER_PATH, '--only', 'core', ...extraArguments],
41
+ { encoding: 'utf8', env: installEnvironment(homeDirectory) },
42
+ );
43
+ assert.equal(installRun.status, 0, installRun.stdout + installRun.stderr);
44
+ }
45
+
46
+ function allHookCommands(settingsPath) {
47
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
48
+ const allCommands = [];
49
+ for (const matcherGroups of Object.values(settings.hooks ?? {})) {
50
+ for (const group of matcherGroups) {
51
+ for (const hook of group.hooks ?? []) {
52
+ if (typeof hook.command === 'string') allCommands.push(hook.command.replace(/\\/g, '/'));
53
+ }
54
+ }
55
+ }
56
+ return allCommands;
57
+ }
58
+
59
+ function entryCountByScript(settingsPath) {
60
+ const hooksConfig = JSON.parse(readFileSync(PACKAGE_HOOKS_JSON_PATH, 'utf8'));
61
+ const allCommands = allHookCommands(settingsPath);
62
+ const countByScript = new Map();
63
+ for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
64
+ const scriptPattern = new RegExp(`/hooks/${relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'";])`, 'g');
65
+ const occurrences = allCommands.reduce(
66
+ (total, command) => total + (command.match(scriptPattern) ?? []).length,
67
+ 0,
68
+ );
69
+ countByScript.set(relativePath, occurrences);
70
+ }
71
+ return countByScript;
72
+ }
73
+
74
+ function expectedCountByScript(hooksConfig) {
75
+ const countByScript = new Map();
76
+ for (const matcherGroups of Object.values(hooksConfig.hooks)) {
77
+ for (const group of matcherGroups) {
78
+ for (const hook of group.hooks) {
79
+ for (const match of hook.command.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g)) {
80
+ countByScript.set(match[1], (countByScript.get(match[1]) ?? 0) + 1);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return countByScript;
86
+ }
87
+
88
+ function assertOneEntryPerHookRegistration(settingsPath, stageName) {
89
+ const hooksConfig = JSON.parse(readFileSync(PACKAGE_HOOKS_JSON_PATH, 'utf8'));
90
+ const expectedCounts = expectedCountByScript(hooksConfig);
91
+ const actualCounts = entryCountByScript(settingsPath);
92
+ assert.ok(expectedCounts.size > 0, 'the package ships managed hook scripts');
93
+ for (const [relativePath, expectedCount] of expectedCounts) {
94
+ assert.equal(
95
+ actualCounts.get(relativePath),
96
+ expectedCount,
97
+ `${stageName}: ${relativePath} is registered once per hooks.json entry`,
98
+ );
99
+ }
100
+ }
101
+
102
+ function withSharedSettingsHome(runScenario) {
103
+ return t => {
104
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-shared-settings-'));
105
+ try {
106
+ writeFileSync(join(homeDirectory, '.gitconfig'), '');
107
+ const mainRoot = join(homeDirectory, '.claude');
108
+ const profileRoot = join(homeDirectory, 'profiles', 'second');
109
+ mkdirSync(mainRoot, { recursive: true });
110
+ mkdirSync(profileRoot, { recursive: true });
111
+ const mainSettingsPath = join(mainRoot, 'settings.json');
112
+ writeFileSync(mainSettingsPath, '{}\n');
113
+ try {
114
+ symlinkSync(mainSettingsPath, join(profileRoot, 'settings.json'), 'file');
115
+ } catch (linkError) {
116
+ t.skip(`this host cannot create a file symlink (${linkError.code})`);
117
+ return;
118
+ }
119
+ runScenario({ homeDirectory, profileRoot, mainSettingsPath });
120
+ } finally {
121
+ rmSync(homeDirectory, { recursive: true, force: true });
122
+ }
123
+ };
124
+ }
125
+
126
+ test(
127
+ 'main then profile then main keeps one entry per hook in the shared settings file',
128
+ withSharedSettingsHome(({ homeDirectory, profileRoot, mainSettingsPath }) => {
129
+ runCoreInstall(homeDirectory, []);
130
+ assertOneEntryPerHookRegistration(mainSettingsPath, 'after the main install');
131
+ runCoreInstall(homeDirectory, ['--target', profileRoot]);
132
+ assertOneEntryPerHookRegistration(mainSettingsPath, 'after the profile install');
133
+ runCoreInstall(homeDirectory, []);
134
+ assertOneEntryPerHookRegistration(mainSettingsPath, 'after the main rerun');
135
+ }),
136
+ );
137
+
138
+ test(
139
+ 'profile then main then profile keeps one entry per hook in the shared settings file',
140
+ withSharedSettingsHome(({ homeDirectory, profileRoot, mainSettingsPath }) => {
141
+ runCoreInstall(homeDirectory, ['--target', profileRoot]);
142
+ assertOneEntryPerHookRegistration(mainSettingsPath, 'after the profile install');
143
+ runCoreInstall(homeDirectory, []);
144
+ assertOneEntryPerHookRegistration(mainSettingsPath, 'after the main install');
145
+ runCoreInstall(homeDirectory, ['--target', profileRoot]);
146
+ assertOneEntryPerHookRegistration(mainSettingsPath, 'after the profile rerun');
147
+ }),
148
+ );
149
+
150
+ test('the merge prunes a hook of a root sharing the settings file and keeps one with its own file', t => {
151
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-shared-settings-unit-'));
152
+ try {
153
+ const installRoot = join(sandboxRoot, 'install-root');
154
+ const sharingRoot = join(sandboxRoot, 'sharing-root');
155
+ const separateRoot = join(sandboxRoot, 'separate-root');
156
+ for (const eachRoot of [installRoot, sharingRoot, separateRoot]) mkdirSync(eachRoot, { recursive: true });
157
+ const settingsPath = join(installRoot, 'settings.json');
158
+ writeFileSync(settingsPath, '{}\n');
159
+ writeFileSync(join(separateRoot, 'settings.json'), '{}\n');
160
+ try {
161
+ symlinkSync(settingsPath, join(sharingRoot, 'settings.json'), 'file');
162
+ } catch (linkError) {
163
+ t.skip(`this host cannot create a file symlink (${linkError.code})`);
164
+ return;
165
+ }
166
+ const hooksConfig = {
167
+ hooks: {
168
+ SessionStart: [{
169
+ matcher: '',
170
+ hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/start.py' }],
171
+ }],
172
+ },
173
+ };
174
+ const forwardSlashed = rootPath => rootPath.replace(/\\/g, '/');
175
+ const sharingCommand = `python3 "${forwardSlashed(sharingRoot)}/hooks/session/start.py"`;
176
+ const separateCommand = `python3 "${forwardSlashed(separateRoot)}/hooks/session/start.py"`;
177
+ const settings = {
178
+ hooks: {
179
+ SessionStart: [{
180
+ matcher: '',
181
+ hooks: [
182
+ { type: 'command', command: sharingCommand },
183
+ { type: 'command', command: separateCommand },
184
+ ],
185
+ }],
186
+ },
187
+ };
188
+
189
+ mergeHooksIntoSettings(settings, hooksConfig, forwardSlashed(installRoot), 'python3', settingsPath);
190
+
191
+ const allCommands = settings.hooks.SessionStart.flatMap(group => group.hooks.map(hook => hook.command));
192
+ assert.ok(!allCommands.includes(sharingCommand), 'the sharing root entry is pruned');
193
+ assert.ok(allCommands.includes(separateCommand), 'the separate root entry stays');
194
+ assert.equal(
195
+ allCommands.filter(command => command.includes(`${forwardSlashed(installRoot)}/hooks/session/start.py`)).length,
196
+ 1,
197
+ 'the install root entry is written once',
198
+ );
199
+ } finally {
200
+ rmSync(sandboxRoot, { recursive: true, force: true });
201
+ }
202
+ });
@@ -18,7 +18,7 @@ python scripts/account_broker.py run --product claude --report report.json -- cl
18
18
 
19
19
  `check` prints nothing and exits 3 while every account is below its floor. `run` sets only `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
20
20
 
21
- The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. State writes use a sibling temporary file and `os.replace`.
21
+ The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. Every broker process for the same user reads that file, so a spent mark pauses that account for every job. A write holds an exclusive lock on the sibling `state.json.lock`, re-reads the file, merges, and swaps in a sibling temporary file with `os.replace`. The merge keeps the latest reset for each spent mark and the newest meter read for each account.
22
22
 
23
23
  ## Exit codes and report
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.38.0",
3
+ "version": "8.38.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import subprocess
8
+ import sys
8
9
  import tempfile
9
10
  import threading
10
11
  from contextlib import contextmanager
@@ -12,6 +13,11 @@ from datetime import datetime, timezone
12
13
  from pathlib import Path
13
14
  from typing import Iterator, Sequence
14
15
 
16
+ if sys.platform == "win32":
17
+ import msvcrt
18
+ else:
19
+ import fcntl
20
+
15
21
  import codex_account_choice
16
22
  import codex_account_meters
17
23
  from claude_account_profile import default_profile_home, validate_profile_name
@@ -19,6 +25,7 @@ from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
19
25
  from dev_env_scripts_constants.account_broker_constants import (
20
26
  BROKER_STATE_DIRECTORY_NAME,
21
27
  BROKER_STATE_FILE_NAME,
28
+ BROKER_STATE_LOCK_SUFFIX,
22
29
  BROKER_STATE_TEMP_SUFFIX,
23
30
  Account,
24
31
  BrokerConfigurationError,
@@ -222,18 +229,79 @@ def _load_state(path: Path) -> dict[str, object]:
222
229
  return {key: parsed_state.get(key) if isinstance(parsed_state.get(key), dict) else {} for key in ("meters", "spent", "affinity")}
223
230
 
224
231
 
225
- def _save_state(path: Path, all_state: dict[str, object]) -> None:
226
- path.parent.mkdir(parents=True, exist_ok=True)
227
- temporary_name: str | None = None
232
+ if sys.platform == "win32":
233
+
234
+ def _acquire_state_lock(lock_descriptor: int) -> None:
235
+ os.lseek(lock_descriptor, 0, os.SEEK_SET)
236
+ msvcrt.locking(lock_descriptor, msvcrt.LK_LOCK, 1)
237
+
238
+ def _release_state_lock(lock_descriptor: int) -> None:
239
+ os.lseek(lock_descriptor, 0, os.SEEK_SET)
240
+ msvcrt.locking(lock_descriptor, msvcrt.LK_UNLCK, 1)
241
+
242
+ else:
243
+
244
+ def _acquire_state_lock(lock_descriptor: int) -> None:
245
+ fcntl.flock(lock_descriptor, fcntl.LOCK_EX)
246
+
247
+ def _release_state_lock(lock_descriptor: int) -> None:
248
+ fcntl.flock(lock_descriptor, fcntl.LOCK_UN)
249
+
250
+
251
+ @contextmanager
252
+ def _state_lock(path: Path) -> Iterator[None]:
253
+ """Hold an exclusive operating-system lock on the state file's sibling lock file."""
254
+ lock_descriptor = os.open(path.with_name(path.name + BROKER_STATE_LOCK_SUFFIX), os.O_CREAT | os.O_RDWR)
228
255
  try:
229
- with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix="state-", suffix=BROKER_STATE_TEMP_SUFFIX, delete=False, newline="\n") as stream:
230
- temporary_name = stream.name
231
- json.dump(all_state, stream, indent=REPORT_INDENT_SPACES, sort_keys=True)
232
- stream.write("\n")
233
- os.replace(temporary_name, path)
256
+ _acquire_state_lock(lock_descriptor)
257
+ try:
258
+ yield
259
+ finally:
260
+ _release_state_lock(lock_descriptor)
234
261
  finally:
235
- if temporary_name is not None and os.path.exists(temporary_name):
236
- os.unlink(temporary_name)
262
+ os.close(lock_descriptor)
263
+
264
+
265
+ def _read_at(entry: object) -> float:
266
+ if not isinstance(entry, dict):
267
+ return float("-inf")
268
+ read_at = entry.get("read_at")
269
+ return float(read_at) if isinstance(read_at, (int, float)) else float("-inf")
270
+
271
+
272
+ def _merged_state(all_saved_state: dict[str, object], all_unsaved_state: dict[str, object]) -> dict[str, object]:
273
+ """Combine two state documents so each writer keeps the other writer's entries.
274
+
275
+ A spent mark keeps its latest reset, a meter entry keeps its newest read,
276
+ and the unsaved session bindings take their keys.
277
+ """
278
+ all_meters = dict(all_saved_state["meters"])
279
+ for each_key, each_entry in all_unsaved_state["meters"].items():
280
+ if _read_at(each_entry) >= _read_at(all_meters.get(each_key)):
281
+ all_meters[each_key] = each_entry
282
+ all_spent = dict(all_saved_state["spent"])
283
+ for each_key, each_reset in all_unsaved_state["spent"].items():
284
+ saved_reset = all_spent.get(each_key)
285
+ if not isinstance(saved_reset, (int, float)) or (isinstance(each_reset, (int, float)) and each_reset > saved_reset):
286
+ all_spent[each_key] = each_reset
287
+ return {"meters": all_meters, "spent": all_spent, "affinity": {**all_saved_state["affinity"], **all_unsaved_state["affinity"]}}
288
+
289
+
290
+ def _save_state(path: Path, all_state: dict[str, object]) -> None:
291
+ """Merge the caller's state into the saved file under a lock, then refresh the caller's copy."""
292
+ path.parent.mkdir(parents=True, exist_ok=True)
293
+ with _state_lock(path):
294
+ all_state.update(_merged_state(_load_state(path), all_state))
295
+ temporary_name: str | None = None
296
+ try:
297
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix="state-", suffix=BROKER_STATE_TEMP_SUFFIX, delete=False, newline="\n") as stream:
298
+ temporary_name = stream.name
299
+ json.dump(all_state, stream, indent=REPORT_INDENT_SPACES, sort_keys=True)
300
+ stream.write("\n")
301
+ os.replace(temporary_name, path)
302
+ finally:
303
+ if temporary_name is not None and os.path.exists(temporary_name):
304
+ os.unlink(temporary_name)
237
305
 
238
306
 
239
307
  def _state_key(account: Account) -> str:
@@ -49,6 +49,7 @@ REPORT_INDENT_SPACES = 2
49
49
  BROKER_STATE_DIRECTORY_NAME = "account-broker"
50
50
  BROKER_STATE_FILE_NAME = "state.json"
51
51
  BROKER_STATE_TEMP_SUFFIX = ".json"
52
+ BROKER_STATE_LOCK_SUFFIX = ".lock"
52
53
  SECONDS_PER_HOUR = 3600
53
54
 
54
55
 
@@ -105,3 +105,23 @@ def test_should_extract_session_id_and_restore_runner() -> None:
105
105
  assert support.subprocess_runner is runner
106
106
  assert support.subprocess_runner is original
107
107
  assert support.extract_session_id_from_stdout('{"session_id":"one"}') == "one"
108
+
109
+
110
+ def test_should_keep_every_concurrent_spent_mark_and_newest_meter_reading(tmp_path: Path) -> None:
111
+ state_path = tmp_path / "broker" / "state.json"
112
+ first_worker = support._load_state(state_path)
113
+ second_worker = support._load_state(state_path)
114
+ first_worker["spent"]["codex:one"] = 2000.0
115
+ first_worker["meters"]["codex:one"] = {"read_at": 500.0, "meters": None}
116
+ support._save_state(state_path, first_worker)
117
+ second_worker["spent"]["codex:two"] = 3000.0
118
+ second_worker["meters"]["codex:one"] = {"read_at": 100.0, "meters": None}
119
+ support._save_state(state_path, second_worker)
120
+ stale_worker = {"meters": {}, "spent": {"codex:one": 1000.0}, "affinity": {"session": "one"}}
121
+ support._save_state(state_path, stale_worker)
122
+
123
+ saved = support._load_state(state_path)
124
+
125
+ assert saved["spent"] == {"codex:one": 2000.0, "codex:two": 3000.0}
126
+ assert saved["meters"]["codex:one"]["read_at"] == 500.0
127
+ assert saved["affinity"] == {"session": "one"}