opencode-agent-hub 1.3.2__tar.gz → 1.3.3__tar.gz

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 (32) hide show
  1. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/PKG-INFO +1 -1
  2. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/pyproject.toml +1 -1
  3. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/src/opencode_agent_hub/__init__.py +1 -1
  4. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/src/opencode_agent_hub/daemon.py +103 -14
  5. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/.gitignore +0 -0
  6. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/LICENSE +0 -0
  7. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/README.md +0 -0
  8. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/coordinator/AGENTS.md +0 -0
  9. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/coordinator/opencode.json +0 -0
  10. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/launchd/com.xnoto.agent-hub-daemon.plist +0 -0
  11. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/aur/PKGBUILD +0 -0
  12. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/changelog +0 -0
  13. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/control +0 -0
  14. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/copyright +0 -0
  15. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/docs +0 -0
  16. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/install +0 -0
  17. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/postinst +0 -0
  18. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/rules +0 -0
  19. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/debian/source/format +0 -0
  20. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/packaging/rpm/opencode-agent-hub.spec +0 -0
  21. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/contrib/systemd/agent-hub-daemon.service +0 -0
  22. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/src/opencode_agent_hub/py.typed +0 -0
  23. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/src/opencode_agent_hub/watch.py +0 -0
  24. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/__init__.py +0 -0
  25. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_config.py +0 -0
  26. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_coordinator.py +0 -0
  27. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_coordinator_cost.py +0 -0
  28. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_orientation_retry.py +0 -0
  29. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_placeholder.py +0 -0
  30. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_rate_limiting.py +0 -0
  31. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_session_agents.py +0 -0
  32. {opencode_agent_hub-1.3.2 → opencode_agent_hub-1.3.3}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-agent-hub
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: Multi-agent coordination daemon and tools for OpenCode
5
5
  Project-URL: Homepage, https://github.com/xnoto/opencode-agent-hub
6
6
  Project-URL: Repository, https://github.com/xnoto/opencode-agent-hub
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opencode-agent-hub"
7
- version = "1.3.2"
7
+ version = "1.3.3"
8
8
  description = "Multi-agent coordination daemon and tools for OpenCode"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,4 +8,4 @@ from importlib.metadata import PackageNotFoundError, version
8
8
  try:
9
9
  __version__ = version("opencode-agent-hub")
10
10
  except PackageNotFoundError: # pragma: no cover - fallback for dev
11
- __version__ = "1.3.2"
11
+ __version__ = "1.3.3"
@@ -78,6 +78,41 @@ import requests
78
78
  from watchdog.events import FileSystemEvent, FileSystemEventHandler
79
79
  from watchdog.observers import Observer
80
80
 
81
+ # =============================================================================
82
+ # Atomic File Operations
83
+ # =============================================================================
84
+
85
+
86
+ def atomic_write_json(path: Path, data: Any, indent: int | None = 2) -> None:
87
+ """Write JSON to file atomically using temp file + rename.
88
+
89
+ This prevents readers from seeing partial/empty files during writes.
90
+ POSIX guarantees rename() is atomic - readers see old or new, never partial.
91
+
92
+ Args:
93
+ path: Target file path
94
+ data: JSON-serializable data
95
+ indent: JSON indentation (None for compact, 2 for pretty-print)
96
+
97
+ Raises:
98
+ OSError: If write fails
99
+ """
100
+ json_str = json.dumps(data, indent=indent)
101
+ temp_path = path.with_suffix(f".tmp.{os.getpid()}")
102
+
103
+ try:
104
+ # Write to temp file first
105
+ temp_path.write_text(json_str, encoding="utf-8")
106
+
107
+ # Atomic rename - readers see either old or new, never partial
108
+ temp_path.rename(path)
109
+ except Exception:
110
+ # Clean up temp file on any error
111
+ with suppress(OSError):
112
+ temp_path.unlink()
113
+ raise
114
+
115
+
81
116
  # =============================================================================
82
117
  # Configuration
83
118
  # =============================================================================
@@ -684,7 +719,7 @@ def save_oriented_sessions() -> None:
684
719
  """Save oriented sessions to disk."""
685
720
  try:
686
721
  AGENT_HUB_DIR.mkdir(parents=True, exist_ok=True)
687
- ORIENTED_SESSIONS_FILE.write_text(json.dumps(list(ORIENTED_SESSIONS)))
722
+ atomic_write_json(ORIENTED_SESSIONS_FILE, list(ORIENTED_SESSIONS), indent=None)
688
723
  except OSError as e:
689
724
  log.warning(f"Failed to save oriented sessions: {e}")
690
725
 
@@ -693,7 +728,7 @@ def save_session_agents() -> None:
693
728
  """Save session-to-agent mapping to disk."""
694
729
  try:
695
730
  AGENT_HUB_DIR.mkdir(parents=True, exist_ok=True)
696
- SESSION_AGENTS_FILE.write_text(json.dumps(SESSION_AGENTS, indent=2))
731
+ atomic_write_json(SESSION_AGENTS_FILE, SESSION_AGENTS, indent=2)
697
732
  except OSError as e:
698
733
  log.warning(f"Failed to save session agents: {e}")
699
734
 
@@ -784,19 +819,73 @@ def record_message_sent(agent_id: str) -> None:
784
819
 
785
820
 
786
821
  def load_agents() -> dict[str, dict[str, Any]]:
787
- """Load all registered agents, keyed by agent ID."""
822
+ """Load all registered agents, keyed by agent ID.
823
+
824
+ Retries on JSON decode errors to handle transient file states
825
+ when external writers are updating agent files.
826
+ """
788
827
  agents: dict[str, dict[str, Any]] = {}
789
828
  if not AGENTS_DIR.exists():
790
829
  return agents
791
830
  for f in AGENTS_DIR.glob("*.json"):
792
- try:
793
- agent = json.loads(f.read_text())
831
+ agent = _load_agent_with_retry(f)
832
+ if agent:
794
833
  agents[agent["id"]] = agent
795
- except (json.JSONDecodeError, KeyError) as e:
796
- log.warning(f"Failed to load agent {f}: {e}")
797
834
  return agents
798
835
 
799
836
 
837
+ def _load_agent_with_retry(
838
+ path: Path, max_retries: int = 3, base_delay: float = 0.05
839
+ ) -> dict[str, Any] | None:
840
+ """Load a single agent file with retry for transient errors.
841
+
842
+ Args:
843
+ path: Path to agent JSON file
844
+ max_retries: Maximum number of retry attempts
845
+ base_delay: Initial delay between retries (doubles each attempt)
846
+
847
+ Returns:
848
+ Agent dict or None if loading failed after all retries
849
+ """
850
+ for attempt in range(max_retries):
851
+ try:
852
+ content = path.read_text()
853
+ if not content.strip():
854
+ # File is empty - writer hasn't finished yet
855
+ if attempt < max_retries - 1:
856
+ delay = base_delay * (2**attempt)
857
+ log.debug(
858
+ f"Agent file {path.name} empty, retrying in {delay:.0f}ms (attempt {attempt + 1}/{max_retries})"
859
+ )
860
+ time.sleep(delay)
861
+ continue
862
+ log.warning(f"Agent file {path.name} is empty after {max_retries} attempts")
863
+ return None
864
+
865
+ agent = json.loads(content)
866
+ if "id" not in agent:
867
+ log.warning(f"Agent file {path.name} missing 'id' field")
868
+ return None
869
+ return agent
870
+
871
+ except json.JSONDecodeError:
872
+ if attempt < max_retries - 1:
873
+ delay = base_delay * (2**attempt)
874
+ log.debug(
875
+ f"Failed to parse agent {path.name}, retrying in {delay:.0f}ms (attempt {attempt + 1}/{max_retries})"
876
+ )
877
+ time.sleep(delay)
878
+ else:
879
+ log.warning(
880
+ f"Failed to load agent {path.name}: JSON decode error after {max_retries} attempts"
881
+ )
882
+ except OSError as e:
883
+ log.warning(f"Failed to read agent {path.name}: {e}")
884
+ return None
885
+
886
+ return None
887
+
888
+
800
889
  def is_agent_active(agent: dict[str, Any]) -> bool:
801
890
  """Check if agent has been seen within the stale threshold."""
802
891
  last_seen = cast(float, agent.get("lastSeen", 0))
@@ -825,7 +914,7 @@ def save_thread(thread: dict[str, Any]) -> None:
825
914
  """Save a thread."""
826
915
  THREADS_DIR.mkdir(parents=True, exist_ok=True)
827
916
  path = THREADS_DIR / f"{thread['id']}.json"
828
- path.write_text(json.dumps(thread, indent=2))
917
+ atomic_write_json(path, thread, indent=2)
829
918
 
830
919
 
831
920
  def create_thread(msg: dict[str, Any]) -> dict[str, Any]:
@@ -1793,10 +1882,10 @@ def start_coordinator() -> bool:
1793
1882
  "status": "active",
1794
1883
  "lastSeen": int(time.time() * 1000),
1795
1884
  }
1796
- # Write agent file directly
1885
+ # Write agent file directly (atomic write)
1797
1886
  AGENTS_DIR.mkdir(parents=True, exist_ok=True)
1798
1887
  agent_file = AGENTS_DIR / "coordinator.json"
1799
- agent_file.write_text(json.dumps(coordinator_agent, indent=2))
1888
+ atomic_write_json(agent_file, coordinator_agent, indent=2)
1800
1889
  log.info("Registered coordinator as agent 'coordinator'")
1801
1890
 
1802
1891
  # Send bootstrap prompt synchronously before waiting for READY.
@@ -2246,10 +2335,10 @@ def get_or_create_agent_for_directory(
2246
2335
  "autoCreated": True,
2247
2336
  }
2248
2337
 
2249
- # Save to disk
2338
+ # Save to disk (atomic write to prevent readers seeing partial files)
2250
2339
  agent_file = AGENTS_DIR / f"{agent_id}.json"
2251
2340
  try:
2252
- agent_file.write_text(json.dumps(agent, indent=2))
2341
+ atomic_write_json(agent_file, agent, indent=2)
2253
2342
  agents[agent_id] = agent
2254
2343
  metrics.inc("agent_hub_agents_auto_created_total")
2255
2344
  metrics.set_gauge("agent_hub_active_agents", len(agents))
@@ -2332,10 +2421,10 @@ def get_or_create_agent_for_session(
2332
2421
  }
2333
2422
  save_session_agents()
2334
2423
 
2335
- # Save agent to disk
2424
+ # Save agent to disk (atomic write to prevent readers seeing partial files)
2336
2425
  agent_file = AGENTS_DIR / f"{agent_id}.json"
2337
2426
  try:
2338
- agent_file.write_text(json.dumps(agent, indent=2))
2427
+ atomic_write_json(agent_file, agent, indent=2)
2339
2428
  agents[agent_id] = agent
2340
2429
  metrics.inc("agent_hub_agents_auto_created_total")
2341
2430
  metrics.set_gauge("agent_hub_active_agents", len(agents))