code-context-control 2.118.0__py3-none-any.whl → 2.118.2__py3-none-any.whl

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.
cli/c3.py CHANGED
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.118.0"
95
+ __version__ = "2.118.2"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
cli/hub_server.py CHANGED
@@ -32,6 +32,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
32
32
  from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name
33
33
  from services import access_guard
34
34
  from services.activity_log import ActivityLog
35
+ from services.atomic_json import write_json_atomic
35
36
  from services.project_manager import ProjectManager
36
37
  from services.tool_classifier import CATEGORIES
37
38
 
@@ -3078,9 +3079,10 @@ def api_projects_config_put():
3078
3079
  else:
3079
3080
  merged[key] = val
3080
3081
  cfg[section] = merged
3081
- tmp = cfg_file.with_name("config.json.tmp")
3082
- tmp.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
3083
- os.replace(tmp, cfg_file)
3082
+ # Not `config.json.tmp`: the CLI (services.access_guard) and the mobile
3083
+ # API write this same file, and the hub itself is threaded — one shared
3084
+ # temp name is a name they all pick at once. See services.atomic_json.
3085
+ write_json_atomic(cfg_file, cfg, trailing_newline=False)
3084
3086
  try:
3085
3087
  ActivityLog(str(resolved)).log("hub_config_write", {
3086
3088
  "section": section, "keys": sorted(values.keys()), "source": "hub"})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.118.0
3
+ Version: 2.118.2
4
4
  Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,7 +1,7 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=lxYVHSqK4FTiFU2avXW2hbOheMbaaZhnAZqqwqtrZHI,20862
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=CwR3bZWG4u7WKNOzjU8c5X823iDqGU5Ra2pleDjgCOs,417916
4
+ cli/c3.py,sha256=7qtAuI6yD-23IZoP5aq78nG8lAdZ1FEQ7if9wcfplAc,417916
5
5
  cli/docs.html,sha256=X9M7qC7LWi8pPJNF-4yEM57jhuZ3WeTESECbdDOeNaA,143172
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
7
  cli/hook_access_guard.py,sha256=ba8-un6G9Ap8HCzx7nyhVq3c_G4sOtiVXRy0BzzX8-Y,23349
@@ -19,7 +19,7 @@ cli/hook_prompt_recall.py,sha256=LHyubu36pf5h3hbLBe0yH-yGhAlwLJdsc7hfa7WB1h4,347
19
19
  cli/hook_read.py,sha256=ZWZldgqGSNz78COhlgETQ7yxdOW5vWIIHUet9gZKhxk,8176
20
20
  cli/hook_session_stats.py,sha256=NbB6_JqUjC2HBfWQTyVLibTqB74s89dhUDvTG6APvcM,5479
21
21
  cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
22
- cli/hub_server.py,sha256=MKoquU5-r4oZJHTi7KCBivBZI0jJuwoHkg_HarQIy5c,184349
22
+ cli/hub_server.py,sha256=4BxvRHCGNc87jOtWankOVjQLYNk-GhWI2OY1harC8ro,184548
23
23
  cli/hub_ui.html,sha256=nnGqWhWGCLfNisGUnoAlQFcBr6wPNsq-AEOR7uFtVkE,2942
24
24
  cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
25
25
  cli/mcp_server.py,sha256=ZkYkCAchuxEaVyU4kxOQHmQj5xDy_Sj4JElCQmrp2VY,64517
@@ -122,7 +122,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
122
122
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
123
123
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
124
124
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
125
- code_context_control-2.118.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
125
+ code_context_control-2.118.2.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
126
126
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
127
127
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
128
128
  core/ide.py,sha256=V6VVMVsFdmmcsMyxikjQp7z9xa42CWiHKS-ya-MAcG4,6172
@@ -148,7 +148,7 @@ oracle/services/insight_engine.py,sha256=lwcdXtjt3CSZLqHVpXx6SG5gDhZYUKwXt4Z8dGy
148
148
  oracle/services/local_session.py,sha256=nkawOjDbotUp5wtZQnC1wPDw3HopNYMkoYqWpP5ToCU,9227
149
149
  oracle/services/memory_reader.py,sha256=rSEmoIXQu2JkolHJIQWpn0piPJpyuf1KZvW_9liQwkU,3727
150
150
  oracle/services/memory_writer.py,sha256=hpvYsxjhl293jEjGcRKfjG5njUaAOXKqbmJaUu1WdUU,6997
151
- oracle/services/mobile_api.py,sha256=TqlGQLpeXu9c8cFi0yNNqUTc2gM5_LA8mJVzi8q2IcA,120130
151
+ oracle/services/mobile_api.py,sha256=9nYE84FRHtgiwv6b9Hs17o3pKYXtPlYCn1Xny_gMLhk,120326
152
152
  oracle/services/project_scanner.py,sha256=PkYJtwOLRME1EzwRy3GIkYk6Xw0u1_BzFcaMJ5gJ1_s,6118
153
153
  oracle/services/review_agent.py,sha256=m_fzoHowpiibf0_iz3dF72LWSrwB_FbDd1am-SbeWXg,11273
154
154
  oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
@@ -173,14 +173,15 @@ oracle/ui/chat/send.js,sha256=X_Uc6EuuIxkUTlRWGGYOq6gQkJCQeCE3p5NMyxx7RzQ,11911
173
173
  oracle/ui/chat/stream_renderer.js,sha256=RZndI2mu5xbzZF-4NG9jeOqXTcmIqfZqxgkrBvTzWmk,29861
174
174
  oracle/ui/chat/toolbar.js,sha256=ZDexIIpGwsQ6XsY4Tyo082rJoofdD_JBTV97-mLQUVM,12027
175
175
  services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
176
- services/access_guard.py,sha256=14SH4aoqgX5CcX6uMC4XBvx6l4tEqMvRM8S83nrAE_c,77070
176
+ services/access_guard.py,sha256=fzK6Uagc-pKmMYykXsDKZVQTV78RE8b3zyLAgFAHc1U,77455
177
177
  services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,9568
178
178
  services/activity_log.py,sha256=PNGeEYlw1Aux-mRU4pKCQdbsSuzLvOg7MYiq-cb4n8g,4473
179
179
  services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
180
- services/agent_locks.py,sha256=qkZ7Fdh3DSlRFBKG4zTe4bmkRg8eJ8B8eTc4-SNDmcY,21964
180
+ services/agent_locks.py,sha256=nRAPuRSoet7BG9oOsNyA5WhlNy-UMhpsq6z5ggIPmLA,22421
181
181
  services/agents.py,sha256=vwfqs8mOlJDXQcGnWOFLPlXnrXraKjiF6lQRsEb0uB4,84469
182
182
  services/artifact_defs.py,sha256=M3blGvW3QhKxoVL1bwU7x9DlnXw1_TUHiOahiyp_Wm0,9749
183
183
  services/artifact_store.py,sha256=vCHpOzOcZmR5VF_BxcvIh6G0rPMl07c1W6Wdu2OkVVA,31098
184
+ services/atomic_json.py,sha256=ZJ64TGw0ft0o8ypqcmye7Pzs_A2XzJ5jcdahoJGslEs,5299
184
185
  services/auto_memory.py,sha256=LAQZtz5qbnn5FSw8hapO9kwbhqLpnQi70IWdNoNQ1V0,15258
185
186
  services/background_service.py,sha256=Hn-vjAUq_wPm0cUiwwzyr-D7Xxkygjuy7s3_9XuLaOs,26866
186
187
  services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
@@ -261,7 +262,7 @@ services/session_benchmark.py,sha256=KUcWWC5X088p_ao6DgglUMwj7jCzdecPKxJ7XiGKob0
261
262
  services/session_manager.py,sha256=jX3rQmilylZJrGnxLjghltoi2rs0k5jszIDQ1iTAGpQ,57025
262
263
  services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
263
264
  services/shell_jobs.py,sha256=p95Sr_lCA04ZkZh3UezeIxxw72_ClDzASaH-T9CV8CA,48581
264
- services/shell_output.py,sha256=y-o_DblNdOl-kBal4cYgUiWZAHa7TLLw6znAA_xABrQ,41828
265
+ services/shell_output.py,sha256=lLvENKqS9N7-hsVw9M7fWUXhBVPaaGRmU9dRx3Vr4Dk,42558
265
266
  services/subprojects.py,sha256=pjGbuWLqC81QxYMai9ZbNxmTJTuwzM7QNiyAqeZG-Yg,41218
266
267
  services/task_store.py,sha256=QoyhLcAyd-JZxn1nMfnZ11tTLgFqY-xwmlx1q8w_1p8,49477
267
268
  services/telemetry.py,sha256=_m7z-RMy_iWQlVYTb52xtJqvvYMbql4MspVvpddOIVg,20176
@@ -297,8 +298,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
297
298
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
298
299
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
299
300
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
300
- code_context_control-2.118.0.dist-info/METADATA,sha256=OjEEfgapyEY9G8cY-ghz5wvFy7cspM9AoyjVb-8srZ4,26700
301
- code_context_control-2.118.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
302
- code_context_control-2.118.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
303
- code_context_control-2.118.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
304
- code_context_control-2.118.0.dist-info/RECORD,,
301
+ code_context_control-2.118.2.dist-info/METADATA,sha256=J372pWy3pynKt7IYUKIgzgrPcrIASfS2gSo-dFn-enU,26700
302
+ code_context_control-2.118.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
303
+ code_context_control-2.118.2.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
304
+ code_context_control-2.118.2.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
305
+ code_context_control-2.118.2.dist-info/RECORD,,
@@ -56,6 +56,7 @@ from oracle.config import ORACLE_DIR, load_config
56
56
  from oracle.services import api_auth, discovery_audit
57
57
  from oracle.services.api_auth import extract_bearer
58
58
  from oracle.services.tool_registry import _c3_version
59
+ from services.atomic_json import write_json_atomic
59
60
 
60
61
  # 2: /info reports EFFECTIVE capabilities (filtered by the config switches)
61
62
  # rather than a static list, so a client can gate its UI instead of probing.
@@ -2090,10 +2091,11 @@ def _write_override_section(project: Path, section: dict) -> dict:
2090
2091
  else:
2091
2092
  block[key] = value
2092
2093
  cfg["override"] = block
2093
- tmp = cfg_file.with_name(f"{cfg_file.name}.tmp")
2094
- tmp.write_text(json.dumps(cfg, indent=2, ensure_ascii=False),
2095
- encoding="utf-8")
2096
- tmp.replace(cfg_file)
2094
+ # Shared with the CLI and the hub, and served from a threaded API, so the
2095
+ # publish goes through the one race-safe writer rather than a temp name
2096
+ # every writer of this file would pick. See services.atomic_json.
2097
+ write_json_atomic(cfg_file, cfg, ensure_ascii=False,
2098
+ trailing_newline=False)
2097
2099
  return block
2098
2100
 
2099
2101
 
services/access_guard.py CHANGED
@@ -20,6 +20,8 @@ import re
20
20
  from dataclasses import dataclass
21
21
  from pathlib import Path
22
22
 
23
+ from services.atomic_json import write_json_atomic
24
+
23
25
  # ── Rule model ──────────────────────────────────────────────────────────────
24
26
 
25
27
  _KIND_DENY = "deny"
@@ -1473,11 +1475,17 @@ def _load_config_for_write(cfg: Path) -> tuple:
1473
1475
 
1474
1476
 
1475
1477
  def _write_scope_config(cfg: Path, data: dict) -> None:
1476
- """Atomic same-directory replace; privileged internal write."""
1477
- cfg.parent.mkdir(parents=True, exist_ok=True)
1478
- tmp = cfg.with_name(cfg.name + ".tmp")
1479
- tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1480
- os.replace(tmp, cfg)
1478
+ """Atomic same-directory replace; privileged internal write.
1479
+
1480
+ The CLI, the hub and the mobile API all reach this for the same scope
1481
+ file, so the publish is via ``services.atomic_json`` — a shared
1482
+ ``config.json.tmp`` is a name they would collide on. The fsync matters
1483
+ more here than anywhere else in the codebase: a truncated ``config.json``
1484
+ comes back from ``load_all`` in its ``corrupt`` list, every path then
1485
+ fails closed with ``<corrupt-config>``, and the session can no longer run
1486
+ the command that would fix it.
1487
+ """
1488
+ write_json_atomic(cfg, data)
1481
1489
 
1482
1490
 
1483
1491
  def seed_default_global_rules() -> dict:
services/agent_locks.py CHANGED
@@ -41,6 +41,7 @@ import time
41
41
  import uuid
42
42
  from pathlib import Path
43
43
 
44
+ from services.atomic_json import write_json_atomic
44
45
  from services.task_store import _FileLock
45
46
 
46
47
  SCHEMA_VERSION = 1
@@ -219,10 +220,15 @@ class LockStore:
219
220
  return doc
220
221
 
221
222
  def _save(self, doc: dict) -> None:
222
- self.data_dir.mkdir(parents=True, exist_ok=True)
223
- tmp = self.state_file.with_name(self.state_file.name + ".tmp")
224
- tmp.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8")
225
- os.replace(tmp, self.state_file)
223
+ # The `_FileLock` guard serialises the read-modify-write, but only
224
+ # among callers that take it — and it is advisory, held per process.
225
+ # The publish itself has to be safe on its own: a shared
226
+ # `locks.json.tmp` is one name every writer on the box picks, and two
227
+ # of them landing together either publishes a half-written lease file
228
+ # or raises WinError 5 (see services.atomic_json). Losing this write
229
+ # means losing live leases, which is exactly what `_load` refuses to
230
+ # do when it quarantines a corrupt file instead of resetting it.
231
+ write_json_atomic(self.state_file, doc)
226
232
 
227
233
  def _guard(self):
228
234
  return _FileLock(self.lock_file)
@@ -0,0 +1,118 @@
1
+ """One durable same-directory replace for the small JSON stores C3 writes.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ C3 keeps a handful of tiny JSON files that more than one writer touches: the
6
+ agent lock store, the per-scope ``config.json`` written by the CLI, the hub
7
+ and the mobile API, and the shell job store. Every one of them had grown the
8
+ same three-line publish step by copy — ``<name>.tmp`` then a single
9
+ ``os.replace`` — and on Windows that shape is a live bug rather than a style
10
+ nit:
11
+
12
+ 1. **A shared temp name races on the TEMP, not on the target.**
13
+ ``os.replace`` is atomic on the destination; nothing at all protects
14
+ ``<name>.tmp``. Two writers of one path both create it, so one publishes
15
+ a file the other is still writing, or the replace raises
16
+ ``PermissionError: [WinError 5] Access is denied`` because the loser still
17
+ holds a handle to it. Observed for real in the shell job store, where it
18
+ crashed a supervisor and reported "failed before running" for a command
19
+ that was fine (fixed there in 2.118.1, PR #157). The other copies had the
20
+ identical shape and no demonstrated failure only because they are written
21
+ far less often — the lock store especially is a coin that had not landed
22
+ on its edge yet.
23
+ 2. **A pid alone is not a unique suffix.** Threads share a pid, and the hub,
24
+ the Oracle and the lock store are all written from threaded servers. Six
25
+ threads are enough to drive a ``.tmp<pid>`` implementation into both
26
+ ``PermissionError`` and ``FileNotFoundError`` within a second, so the
27
+ suffix carries per-WRITE randomness as well.
28
+ 3. **One attempt turns a transient sharing violation into a lost write.** An
29
+ AV scanner or the Search indexer holding the target open for a few
30
+ milliseconds is ordinary on Windows and clears on its own; a single
31
+ ``os.replace`` turns it into an exception at the call site.
32
+
33
+ ``cli/_hook_utils._atomic_write_json`` deliberately does NOT import this. A
34
+ PreToolUse hook is a separate, short-lived, single-threaded process that
35
+ imports nothing from ``services`` on purpose, so pid-only is genuinely
36
+ enough there. That one duplication stays.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import os
43
+ import secrets
44
+ import time
45
+ from pathlib import Path
46
+
47
+ # Four attempts across ~0.14s of backoff. Sized for a scanner or indexer
48
+ # holding a handle, not for a lock a peer holds for seconds — a failure that
49
+ # survives this is real and is re-raised.
50
+ REPLACE_ATTEMPTS = 4
51
+ REPLACE_BACKOFF_S = 0.02
52
+
53
+
54
+ def atomic_tmp_path(path: Path) -> Path:
55
+ """``<name>.tmp<pid>-<random>`` beside ``path`` — unique per write."""
56
+ return path.with_name(f"{path.name}.tmp{os.getpid()}-{secrets.token_hex(4)}")
57
+
58
+
59
+ def write_text_atomic(path, text: str, *, encoding: str = "utf-8",
60
+ fsync: bool = True) -> None:
61
+ """Publish ``text`` at ``path``: unique temp, then a retried ``os.replace``.
62
+
63
+ ``fsync`` flushes the temp's bytes before the replace, so a crash or power
64
+ loss between the two cannot publish a zero-length or half-written file.
65
+ Durable state wants that: a truncated ``config.json`` makes the Access
66
+ Guard read ``<corrupt-config>`` and fail closed on every subsequent call,
67
+ which wedges the whole session. Callers whose file is ephemeral spill
68
+ state pass ``fsync=False`` rather than pay a disk flush per update.
69
+
70
+ Newline translation matches ``Path.write_text`` (the default), so this is
71
+ a drop-in for the ``write_text`` + ``os.replace`` pairs it replaces and
72
+ does not churn line endings in files already on disk.
73
+ """
74
+ path = Path(path)
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ tmp = atomic_tmp_path(path)
77
+ try:
78
+ with open(tmp, "w", encoding=encoding) as fh:
79
+ fh.write(text)
80
+ if fsync:
81
+ fh.flush()
82
+ os.fsync(fh.fileno())
83
+ last_exc: OSError | None = None
84
+ for attempt in range(REPLACE_ATTEMPTS):
85
+ try:
86
+ os.replace(tmp, path)
87
+ return
88
+ except PermissionError as exc:
89
+ last_exc = exc
90
+ if attempt < REPLACE_ATTEMPTS - 1:
91
+ time.sleep(REPLACE_BACKOFF_S * (2 ** attempt))
92
+ if last_exc is not None:
93
+ raise last_exc
94
+ finally:
95
+ # A successful replace already consumed tmp, so this only fires on the
96
+ # failure paths — otherwise abandoned temps accumulate next to the
97
+ # file, and for `.c3/` that means they get indexed and committed.
98
+ try:
99
+ if tmp.exists():
100
+ tmp.unlink()
101
+ except OSError:
102
+ pass
103
+
104
+
105
+ def write_json_atomic(path, data, *, indent: int = 2,
106
+ ensure_ascii: bool = True,
107
+ trailing_newline: bool = True,
108
+ fsync: bool = True) -> None:
109
+ """``write_text_atomic`` over ``json.dumps``.
110
+
111
+ The serialisation knobs exist so each caller keeps the exact bytes it
112
+ wrote before this module: the point of the change is the publish step,
113
+ not a reformat of five JSON files that other tools and diffs already read.
114
+ """
115
+ text = json.dumps(data, indent=indent, ensure_ascii=ensure_ascii)
116
+ if trailing_newline:
117
+ text += "\n"
118
+ write_text_atomic(path, text, fsync=fsync)
services/shell_output.py CHANGED
@@ -75,6 +75,8 @@ from dataclasses import dataclass, field
75
75
  from datetime import datetime, timedelta, timezone
76
76
  from pathlib import Path
77
77
 
78
+ from services.atomic_json import write_json_atomic
79
+
78
80
  __all__ = [
79
81
  "STORE_ROOT", "RETENTION_DAYS", "RETENTION_BYTES", "HEAD_BYTES", "TAIL_BYTES",
80
82
  "TEXT_MAX_BYTES", "DEFAULT_READ_BYTES", "LINE_CLIP_CHARS",
@@ -961,9 +963,21 @@ def _load_meta(path: Path) -> OutputMeta | None:
961
963
 
962
964
 
963
965
  def _write_json_atomic(path: Path, data: dict) -> None:
964
- tmp = path.with_name(path.name + ".tmp")
965
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
966
- os.replace(tmp, path)
966
+ """Publish a job record durably — see ``services.atomic_json``.
967
+
968
+ This was the first copy of the unique-temp + retried-``os.replace`` shape
969
+ (2.118.1, PR #157, after a shared ``<name>.tmp`` crashed supervisors on
970
+ Windows CI with WinError 5 and reported "failed before running" for
971
+ commands that were fine). The implementation now lives in one place so the
972
+ lock store and the three ``config.json`` writers share it instead of
973
+ carrying four more copies of a bug.
974
+
975
+ ``fsync=False`` is the one thing kept local: a job record is ephemeral
976
+ spill state that a crash invalidates anyway, so a disk flush per status
977
+ update buys nothing. The durable stores take the default.
978
+ """
979
+ write_json_atomic(path, data, indent=1, ensure_ascii=False,
980
+ trailing_newline=False, fsync=False)
967
981
 
968
982
 
969
983
  def _move(src: Path, dst: Path) -> None: