cluxion-agentplugin-preprocessing 0.3.2__tar.gz → 0.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 (85) hide show
  1. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/PKG-INFO +1 -1
  2. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/pyproject.toml +1 -1
  3. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/__init__.py +1 -1
  4. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/dispatch_store.py +80 -48
  5. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/resources/py_queue.py +87 -49
  6. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_dispatch_store.py +72 -0
  7. cluxion_agentplugin_preprocessing-0.3.3/tests/runtime/test_py_queue_concurrency.py +160 -0
  8. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/.github/profile/README.md +0 -0
  9. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/.gitignore +0 -0
  10. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/Docs/README.md +0 -0
  11. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/LICENSE +0 -0
  12. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/README.md +0 -0
  13. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/adapters/claude/.claude-plugin/plugin.json +0 -0
  14. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/adapters/claude/skills/preprocess/SKILL.md +0 -0
  15. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/adapters/codex/config-snippet.toml +0 -0
  16. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/cluxion-Docs/README.md +0 -0
  17. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/cluxion-Docs/architecture.md +0 -0
  18. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/cluxion-Docs/harness-logic.md +0 -0
  19. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/cluxion-Docs/honesty-preprocessing.md +0 -0
  20. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/cluxion-Docs/install-and-operations.md +0 -0
  21. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/cluxion-Docs/security.md +0 -0
  22. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/Cargo.lock +0 -0
  23. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/Cargo.toml +0 -0
  24. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/pyproject.toml +0 -0
  25. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/context.rs +0 -0
  26. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/dispatch.rs +0 -0
  27. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/guard.rs +0 -0
  28. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/lib.rs +0 -0
  29. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/main.rs +0 -0
  30. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/queue.rs +0 -0
  31. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/rust/cluxion_queue/src/types.rs +0 -0
  32. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/cli.py +0 -0
  33. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/guard_watch.py +0 -0
  34. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/hermes_config.py +0 -0
  35. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/plugin.py +0 -0
  36. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/plugin.yaml +0 -0
  37. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/runner.py +0 -0
  38. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_agentplugin_preprocessing/schemas.py +0 -0
  39. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/__init__.py +0 -0
  40. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/__main__.py +0 -0
  41. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/adapters/__init__.py +0 -0
  42. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/adapters/contract.py +0 -0
  43. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/adapters/grok_build.py +0 -0
  44. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/adapters/hermes.py +0 -0
  45. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/adapters/spec.py +0 -0
  46. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/bootstrap.py +0 -0
  47. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/cli.py +0 -0
  48. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/__init__.py +0 -0
  49. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/clarification.py +0 -0
  50. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/context_compress.py +0 -0
  51. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/harness.py +0 -0
  52. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/intent.py +0 -0
  53. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/ledger.py +0 -0
  54. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/ledger_codec.py +0 -0
  55. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/plan_codec.py +0 -0
  56. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/preprocess.py +0 -0
  57. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/types.py +0 -0
  58. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/core/work_queue.py +0 -0
  59. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/guard_daemon_host.py +0 -0
  60. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/models/__init__.py +0 -0
  61. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/models/supervisor.py +0 -0
  62. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/models/vllm_mlx.py +0 -0
  63. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/resources/__init__.py +0 -0
  64. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/resources/guard_bridge.py +0 -0
  65. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/resources/queue_bridge.py +0 -0
  66. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/resources/rust_bridge.py +0 -0
  67. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/web/__init__.py +0 -0
  68. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/src/cluxion_runtime/web/browser_bridge.py +0 -0
  69. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_browser_bridge.py +0 -0
  70. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_clarification.py +0 -0
  71. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_cluxion_runtime_spine.py +0 -0
  72. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_context_compress.py +0 -0
  73. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_contract.py +0 -0
  74. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_guard.py +0 -0
  75. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_ledger.py +0 -0
  76. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_queue_backends.py +0 -0
  77. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_runtime_adapter_cli.py +0 -0
  78. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_rust_queue.py +0 -0
  79. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/runtime/test_supervisor.py +0 -0
  80. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/test_bootstrap.py +0 -0
  81. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/test_guard_watch.py +0 -0
  82. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/test_hermes_config.py +0 -0
  83. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/test_packaging_policy.py +0 -0
  84. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/test_plugin.py +0 -0
  85. {cluxion_agentplugin_preprocessing-0.3.2 → cluxion_agentplugin_preprocessing-0.3.3}/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cluxion-agentplugin-preprocessing
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Universal agent plugin for Cluxion preprocessing, honesty contracts, clarification, Rust work queue, and resource-aware harness handoff.
5
5
  Project-URL: Homepage, https://github.com/cluxion/cluxion-Agentplugin-preprocessing
6
6
  Project-URL: Repository, https://github.com/cluxion/cluxion-Agentplugin-preprocessing
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cluxion-agentplugin-preprocessing"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Universal agent plugin for Cluxion preprocessing, honesty contracts, clarification, Rust work queue, and resource-aware harness handoff."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.2"
5
+ __version__ = "0.3.3"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -5,11 +5,18 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import time
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
8
10
  from dataclasses import dataclass
9
11
  from pathlib import Path
10
12
  from tempfile import NamedTemporaryFile
11
13
  from typing import TYPE_CHECKING
12
14
 
15
+ try:
16
+ import fcntl as _fcntl
17
+ except ImportError: # pragma: no cover - exercised only on non-POSIX platforms.
18
+ _fcntl = None
19
+
13
20
  if TYPE_CHECKING:
14
21
  from cluxion_runtime.core.types import HarnessPlan, QueueSegment
15
22
 
@@ -63,48 +70,43 @@ def persist_dispatch_bundle(plan: HarnessPlan, *, dispatch_dir: Path | None = No
63
70
  pass
64
71
  target_dir.mkdir(parents=True, exist_ok=True)
65
72
  path = _bundle_path(plan.item.work_id, target_dir)
66
- _atomic_write_json(path, bundle)
73
+ with _exclusive_bundle_lock(path):
74
+ _atomic_write_json(path, bundle)
67
75
  return path
68
76
 
69
77
 
70
78
  def load_dispatch_bundle(work_id: str, *, dispatch_dir: Path | None = None) -> dict[str, object]:
71
79
  """Read the dispatch bundle for a work_id."""
72
80
  path = _bundle_path(work_id, default_dispatch_dir() if dispatch_dir is None else dispatch_dir)
73
- if not path.exists():
74
- raise DispatchStoreError(f"dispatch bundle not found: {work_id}")
75
- try:
76
- payload = json.loads(path.read_text(encoding="utf-8"))
77
- except json.JSONDecodeError as exc:
78
- raise DispatchStoreError(f"dispatch bundle is invalid JSON: {work_id}") from exc
79
- if not isinstance(payload, dict):
80
- raise DispatchStoreError(f"dispatch bundle must be an object: {work_id}")
81
- return payload
81
+ return _load_dispatch_bundle_from_path(path, work_id)
82
82
 
83
83
 
84
84
  def next_dispatch_step(work_id: str, *, dispatch_dir: Path | None = None) -> dict[str, object]:
85
85
  """Mark the next queued segment as running and return the payload for Hermes."""
86
86
  target_dir = default_dispatch_dir() if dispatch_dir is None else dispatch_dir
87
- bundle = load_dispatch_bundle(work_id, dispatch_dir=target_dir)
88
- steps = _steps(bundle)
89
- for step in steps:
90
- if step.get("status") in {"queued", "retry_wait"}:
91
- step["status"] = "running"
92
- step["updated_at"] = time.time()
93
- _atomic_write_json(_bundle_path(work_id, target_dir), bundle)
94
- return {
95
- "work_id": work_id,
96
- "ready": True,
97
- "step": _public_step(step),
98
- "remaining": _remaining_count(steps),
99
- "synthesis_ready": False,
100
- }
101
- return {
102
- "work_id": work_id,
103
- "ready": False,
104
- "step": {},
105
- "remaining": _remaining_count(steps),
106
- "synthesis_ready": all(step.get("status") == "succeeded" for step in steps),
107
- }
87
+ path = _bundle_path(work_id, target_dir)
88
+ with _exclusive_bundle_lock(path):
89
+ bundle = _load_dispatch_bundle_from_path(path, work_id)
90
+ steps = _steps(bundle)
91
+ for step in steps:
92
+ if step.get("status") in {"queued", "retry_wait"}:
93
+ step["status"] = "running"
94
+ step["updated_at"] = time.time()
95
+ _atomic_write_json(path, bundle)
96
+ return {
97
+ "work_id": work_id,
98
+ "ready": True,
99
+ "step": _public_step(step),
100
+ "remaining": _remaining_count(steps),
101
+ "synthesis_ready": False,
102
+ }
103
+ return {
104
+ "work_id": work_id,
105
+ "ready": False,
106
+ "step": {},
107
+ "remaining": _remaining_count(steps),
108
+ "synthesis_ready": all(step.get("status") == "succeeded" for step in steps),
109
+ }
108
110
 
109
111
 
110
112
  def record_dispatch_result(
@@ -118,23 +120,25 @@ def record_dispatch_result(
118
120
  ) -> dict[str, object]:
119
121
  """Store the segment result produced by the Hermes model."""
120
122
  target_dir = default_dispatch_dir() if dispatch_dir is None else dispatch_dir
121
- bundle = load_dispatch_bundle(work_id, dispatch_dir=target_dir)
122
- steps = _steps(bundle)
123
- for step in steps:
124
- if step.get("step_id") == step_id:
125
- step["status"] = "succeeded" if succeeded else "failed"
126
- step["result"] = result
127
- step["error"] = error
128
- step["updated_at"] = time.time()
129
- _atomic_write_json(_bundle_path(work_id, target_dir), bundle)
130
- return {
131
- "work_id": work_id,
132
- "step_id": step_id,
133
- "recorded": True,
134
- "status": step["status"],
135
- "remaining": _remaining_count(steps),
136
- "synthesis_ready": all(item.get("status") == "succeeded" for item in steps),
137
- }
123
+ path = _bundle_path(work_id, target_dir)
124
+ with _exclusive_bundle_lock(path):
125
+ bundle = _load_dispatch_bundle_from_path(path, work_id)
126
+ steps = _steps(bundle)
127
+ for step in steps:
128
+ if step.get("step_id") == step_id:
129
+ step["status"] = "succeeded" if succeeded else "failed"
130
+ step["result"] = result
131
+ step["error"] = error
132
+ step["updated_at"] = time.time()
133
+ _atomic_write_json(path, bundle)
134
+ return {
135
+ "work_id": work_id,
136
+ "step_id": step_id,
137
+ "recorded": True,
138
+ "status": step["status"],
139
+ "remaining": _remaining_count(steps),
140
+ "synthesis_ready": all(item.get("status") == "succeeded" for item in steps),
141
+ }
138
142
  raise DispatchStoreError(f"dispatch step not found: {work_id}/{step_id}")
139
143
 
140
144
 
@@ -246,6 +250,34 @@ def _bundle_path(work_id: str, dispatch_dir: Path) -> Path:
246
250
  return dispatch_dir / f"{safe}.json"
247
251
 
248
252
 
253
+ def _load_dispatch_bundle_from_path(path: Path, work_id: str) -> dict[str, object]:
254
+ if not path.exists():
255
+ raise DispatchStoreError(f"dispatch bundle not found: {work_id}")
256
+ try:
257
+ payload = json.loads(path.read_text(encoding="utf-8"))
258
+ except json.JSONDecodeError as exc:
259
+ raise DispatchStoreError(f"dispatch bundle is invalid JSON: {work_id}") from exc
260
+ if not isinstance(payload, dict):
261
+ raise DispatchStoreError(f"dispatch bundle must be an object: {work_id}")
262
+ return payload
263
+
264
+
265
+ @contextmanager
266
+ def _exclusive_bundle_lock(path: Path) -> Iterator[None]:
267
+ path.parent.mkdir(parents=True, exist_ok=True)
268
+ if _fcntl is None:
269
+ # Non-POSIX platforms keep atomic rename but skip advisory locking.
270
+ yield
271
+ return
272
+ lock_path = path.with_name(f"{path.name}.lock")
273
+ with lock_path.open("a+b") as lock_file:
274
+ _fcntl.flock(lock_file.fileno(), _fcntl.LOCK_EX)
275
+ try:
276
+ yield
277
+ finally:
278
+ _fcntl.flock(lock_file.fileno(), _fcntl.LOCK_UN)
279
+
280
+
249
281
  def _atomic_write_json(path: Path, payload: dict[str, object]) -> None:
250
282
  path.parent.mkdir(parents=True, exist_ok=True)
251
283
  with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
@@ -12,9 +12,17 @@ import json
12
12
  import os
13
13
  import sqlite3
14
14
  import time
15
+ from collections.abc import Iterator
16
+ from contextlib import contextmanager
15
17
  from pathlib import Path
18
+ from tempfile import NamedTemporaryFile
16
19
  from typing import Any
17
20
 
21
+ try:
22
+ import fcntl as _fcntl
23
+ except ImportError: # pragma: no cover - exercised only on non-POSIX platforms.
24
+ _fcntl = None
25
+
18
26
 
19
27
  def run(command: str, payload: dict[str, Any]) -> dict[str, Any]:
20
28
  store_dir = Path(payload.get("store_dir") or _default_store())
@@ -41,10 +49,11 @@ def _default_store() -> str:
41
49
 
42
50
  def _open_db(store_dir: Path) -> sqlite3.Connection:
43
51
  store_dir.mkdir(parents=True, exist_ok=True)
44
- conn = sqlite3.connect(store_dir / "work_queue.sqlite")
52
+ conn = sqlite3.connect(store_dir / "work_queue.sqlite", timeout=30.0)
45
53
  conn.executescript(
46
54
  """
47
55
  PRAGMA journal_mode=WAL;
56
+ -- NORMAL+WAL can lose the latest commit on OS crash, but avoids corruption and keeps queue throughput balanced.
48
57
  PRAGMA synchronous=NORMAL;
49
58
  CREATE TABLE IF NOT EXISTS work_queue (
50
59
  work_id TEXT PRIMARY KEY,
@@ -64,6 +73,10 @@ def _open_db(store_dir: Path) -> sqlite3.Connection:
64
73
  return conn
65
74
 
66
75
 
76
+ def _begin_immediate(conn: sqlite3.Connection) -> None:
77
+ conn.execute("BEGIN IMMEDIATE")
78
+
79
+
67
80
  def _ok(data: dict[str, Any]) -> dict[str, Any]:
68
81
  return {"ok": True, **data}
69
82
 
@@ -83,6 +96,7 @@ def _enqueue(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
83
96
  metadata_json = json.dumps(payload.get("metadata", {}), ensure_ascii=False)
84
97
  now = time.time()
85
98
  with _open_db(store_dir) as conn:
99
+ _begin_immediate(conn)
86
100
  sequence = conn.execute("SELECT COALESCE(MAX(sequence), 0) + 1 FROM work_queue").fetchone()[0]
87
101
  conn.execute(
88
102
  """INSERT INTO work_queue (work_id, prompt, surface, priority, status, metadata_json, sequence, created_at, updated_at)
@@ -99,6 +113,7 @@ def _enqueue(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
99
113
  def _dequeue(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
100
114
  now = time.time()
101
115
  with _open_db(store_dir) as conn:
116
+ _begin_immediate(conn)
102
117
  row = conn.execute(
103
118
  """SELECT work_id, prompt, surface, priority, metadata_json FROM work_queue
104
119
  WHERE status = 'pending' ORDER BY priority ASC, sequence ASC LIMIT 1"""
@@ -165,9 +180,13 @@ def _read_bundle(path: Path) -> dict[str, Any]:
165
180
 
166
181
  def _write_atomic(path: Path, payload: dict[str, Any]) -> None:
167
182
  path.parent.mkdir(parents=True, exist_ok=True)
168
- temp = path.with_suffix(".json.tmp")
169
- temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
170
- temp.rename(path)
183
+ with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
184
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
185
+ handle.write("\n")
186
+ handle.flush()
187
+ os.fsync(handle.fileno())
188
+ temporary = Path(handle.name)
189
+ temporary.replace(path)
171
190
 
172
191
 
173
192
  def _steps(bundle: dict[str, Any]) -> list[dict[str, Any]]:
@@ -186,7 +205,8 @@ def _persist(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
186
205
  if "bundle" not in payload:
187
206
  raise RuntimeError("missing bundle")
188
207
  path = _bundle_path(store_dir, work_id)
189
- _write_atomic(path, payload["bundle"])
208
+ with _exclusive_bundle_lock(path):
209
+ _write_atomic(path, payload["bundle"])
190
210
  return _ok({"stored": True, "path": str(path)})
191
211
 
192
212
 
@@ -207,31 +227,32 @@ def _public_step(step: dict[str, Any]) -> dict[str, Any]:
207
227
  def _next_step(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
208
228
  work_id = _require_str(payload, "work_id")
209
229
  path = _bundle_path(store_dir, work_id)
210
- bundle = _read_bundle(path)
211
- steps = _steps(bundle)
212
- for step in steps:
213
- if step.get("status") in ("queued", "retry_wait"):
214
- step["status"] = "running"
215
- step["updated_at"] = time.time()
216
- _write_atomic(path, bundle)
217
- return _ok(
218
- {
219
- "work_id": work_id,
220
- "ready": True,
221
- "step": _public_step(step),
222
- "remaining": _remaining(steps),
223
- "synthesis_ready": False,
224
- }
225
- )
226
- return _ok(
227
- {
228
- "work_id": work_id,
229
- "ready": False,
230
- "step": {},
231
- "remaining": _remaining(steps),
232
- "synthesis_ready": all(s.get("status") == "succeeded" for s in steps),
233
- }
234
- )
230
+ with _exclusive_bundle_lock(path):
231
+ bundle = _read_bundle(path)
232
+ steps = _steps(bundle)
233
+ for step in steps:
234
+ if step.get("status") in ("queued", "retry_wait"):
235
+ step["status"] = "running"
236
+ step["updated_at"] = time.time()
237
+ _write_atomic(path, bundle)
238
+ return _ok(
239
+ {
240
+ "work_id": work_id,
241
+ "ready": True,
242
+ "step": _public_step(step),
243
+ "remaining": _remaining(steps),
244
+ "synthesis_ready": False,
245
+ }
246
+ )
247
+ return _ok(
248
+ {
249
+ "work_id": work_id,
250
+ "ready": False,
251
+ "step": {},
252
+ "remaining": _remaining(steps),
253
+ "synthesis_ready": all(s.get("status") == "succeeded" for s in steps),
254
+ }
255
+ )
235
256
 
236
257
 
237
258
  def _record_step(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
@@ -239,28 +260,45 @@ def _record_step(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
239
260
  step_id = _require_str(payload, "step_id")
240
261
  failed = bool(payload.get("failed", False))
241
262
  path = _bundle_path(store_dir, work_id)
242
- bundle = _read_bundle(path)
243
- steps = _steps(bundle)
244
- for step in steps:
245
- if step.get("step_id") == step_id:
246
- step["status"] = "failed" if failed else "succeeded"
247
- step["result"] = payload.get("result", "")
248
- step["error"] = payload.get("error", "")
249
- step["updated_at"] = time.time()
250
- _write_atomic(path, bundle)
251
- return _ok(
252
- {
253
- "work_id": work_id,
254
- "step_id": step_id,
255
- "recorded": True,
256
- "status": step["status"],
257
- "remaining": _remaining(steps),
258
- "synthesis_ready": all(s.get("status") == "succeeded" for s in steps),
259
- }
260
- )
263
+ with _exclusive_bundle_lock(path):
264
+ bundle = _read_bundle(path)
265
+ steps = _steps(bundle)
266
+ for step in steps:
267
+ if step.get("step_id") == step_id:
268
+ step["status"] = "failed" if failed else "succeeded"
269
+ step["result"] = payload.get("result", "")
270
+ step["error"] = payload.get("error", "")
271
+ step["updated_at"] = time.time()
272
+ _write_atomic(path, bundle)
273
+ return _ok(
274
+ {
275
+ "work_id": work_id,
276
+ "step_id": step_id,
277
+ "recorded": True,
278
+ "status": step["status"],
279
+ "remaining": _remaining(steps),
280
+ "synthesis_ready": all(s.get("status") == "succeeded" for s in steps),
281
+ }
282
+ )
261
283
  raise RuntimeError(f"dispatch step not found: {work_id}/{step_id}")
262
284
 
263
285
 
286
+ @contextmanager
287
+ def _exclusive_bundle_lock(path: Path) -> Iterator[None]:
288
+ path.parent.mkdir(parents=True, exist_ok=True)
289
+ if _fcntl is None:
290
+ # Non-POSIX platforms keep atomic rename but skip advisory locking.
291
+ yield
292
+ return
293
+ lock_path = path.with_name(f"{path.name}.lock")
294
+ with lock_path.open("a+b") as lock_file:
295
+ _fcntl.flock(lock_file.fileno(), _fcntl.LOCK_EX)
296
+ try:
297
+ yield
298
+ finally:
299
+ _fcntl.flock(lock_file.fileno(), _fcntl.LOCK_UN)
300
+
301
+
264
302
  def _brief(store_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
265
303
  work_id = _require_str(payload, "work_id")
266
304
  bundle = _read_bundle(_bundle_path(store_dir, work_id))
@@ -1,9 +1,15 @@
1
+ """Concurrency coverage for the file-backed dispatch store."""
2
+
1
3
  from __future__ import annotations
2
4
 
5
+ import threading
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from contextlib import suppress
3
8
  from typing import TYPE_CHECKING
4
9
 
5
10
  import pytest
6
11
 
12
+ from cluxion_runtime.core import dispatch_store
7
13
  from cluxion_runtime.core.dispatch_store import (
8
14
  DispatchStoreError,
9
15
  build_briefing_payload,
@@ -21,6 +27,7 @@ if TYPE_CHECKING:
21
27
  from cluxion_runtime.core.types import HarnessPlan
22
28
 
23
29
  _SNAPSHOT = ResourceSnapshot(total_ram_mb=48_000, available_ram_mb=40_000, swap_used_mb=0, cpu_percent=20.0)
30
+ _RACE_TIMEOUT_SECONDS = 0.25
24
31
 
25
32
 
26
33
  @pytest.fixture(scope="module")
@@ -51,6 +58,29 @@ def _drain_ids(work_id: str, dispatch_dir: Path) -> list[str]:
51
58
  record_dispatch_result(work_id, step_id, result=f"done:{step_id}", dispatch_dir=dispatch_dir)
52
59
 
53
60
 
61
+ def _run_concurrently(worker_count: int, worker) -> list[object]:
62
+ start = threading.Barrier(worker_count)
63
+
64
+ def run(index: int) -> object:
65
+ start.wait()
66
+ return worker(index)
67
+
68
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
69
+ return list(executor.map(run, range(worker_count)))
70
+
71
+
72
+ def _stall_dispatch_writes(monkeypatch: pytest.MonkeyPatch) -> None:
73
+ original_write = dispatch_store._atomic_write_json
74
+ write_barrier = threading.Barrier(2)
75
+
76
+ def write(path: Path, payload: dict[str, object]) -> None:
77
+ with suppress(threading.BrokenBarrierError):
78
+ write_barrier.wait(timeout=_RACE_TIMEOUT_SECONDS)
79
+ original_write(path, payload)
80
+
81
+ monkeypatch.setattr(dispatch_store, "_atomic_write_json", write)
82
+
83
+
54
84
  def test_persist_skips_plans_without_queue(tmp_path: Path) -> None:
55
85
  plan = build_harness_plan(WorkItem("w-short", "작업: 작은 버그를 고쳐줘."), snapshot=_SNAPSHOT)
56
86
  assert plan.execution.queue_required is False
@@ -78,6 +108,22 @@ def test_next_marks_step_running_on_disk(tmp_path: Path, queued_plan: HarnessPla
78
108
  assert statuses.count("running") == 1
79
109
 
80
110
 
111
+ def test_concurrent_next_dispatch_steps_do_not_claim_same_step(
112
+ tmp_path: Path, queued_plan: HarnessPlan, monkeypatch: pytest.MonkeyPatch
113
+ ) -> None:
114
+ """Concurrent queue-next calls must not overwrite each other's running marker."""
115
+ persist_dispatch_bundle(queued_plan, dispatch_dir=tmp_path)
116
+ _stall_dispatch_writes(monkeypatch)
117
+
118
+ results = _run_concurrently(2, lambda _index: next_dispatch_step("w-queued", dispatch_dir=tmp_path))
119
+ step_ids = [str(result["step"]["step_id"]) for result in results if result["ready"]]
120
+
121
+ assert len(step_ids) == 2
122
+ assert len(set(step_ids)) == 2
123
+ statuses = [step["status"] for step in load_dispatch_bundle("w-queued", dispatch_dir=tmp_path)["steps"]]
124
+ assert statuses.count("running") == 2
125
+
126
+
81
127
  def test_next_reports_not_ready_when_nothing_queued(tmp_path: Path, queued_plan: HarnessPlan) -> None:
82
128
  persist_dispatch_bundle(queued_plan, dispatch_dir=tmp_path)
83
129
  while next_dispatch_step("w-queued", dispatch_dir=tmp_path)["ready"]:
@@ -101,6 +147,32 @@ def test_full_drain_unlocks_briefing(tmp_path: Path, queued_plan: HarnessPlan) -
101
147
  assert f"done:{step_id}" in str(briefing["briefing_prompt"])
102
148
 
103
149
 
150
+ def test_concurrent_record_dispatch_results_preserve_both_updates(
151
+ tmp_path: Path, queued_plan: HarnessPlan, monkeypatch: pytest.MonkeyPatch
152
+ ) -> None:
153
+ """Concurrent queue-record calls for different steps must both persist."""
154
+ persist_dispatch_bundle(queued_plan, dispatch_dir=tmp_path)
155
+ step_ids = [
156
+ str(next_dispatch_step("w-queued", dispatch_dir=tmp_path)["step"]["step_id"]),
157
+ str(next_dispatch_step("w-queued", dispatch_dir=tmp_path)["step"]["step_id"]),
158
+ ]
159
+ _stall_dispatch_writes(monkeypatch)
160
+
161
+ _run_concurrently(
162
+ 2,
163
+ lambda index: record_dispatch_result(
164
+ "w-queued",
165
+ step_ids[index],
166
+ result=f"done:{index}",
167
+ dispatch_dir=tmp_path,
168
+ ),
169
+ )
170
+
171
+ steps = {str(step["step_id"]): step for step in load_dispatch_bundle("w-queued", dispatch_dir=tmp_path)["steps"]}
172
+ assert steps[step_ids[0]]["result"] == "done:0"
173
+ assert steps[step_ids[1]]["result"] == "done:1"
174
+
175
+
104
176
  def test_record_unknown_step_raises(tmp_path: Path, queued_plan: HarnessPlan) -> None:
105
177
  persist_dispatch_bundle(queued_plan, dispatch_dir=tmp_path)
106
178
  with pytest.raises(DispatchStoreError):
@@ -0,0 +1,160 @@
1
+ """Concurrency coverage for the pure-Python queue fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import pytest
13
+
14
+ from cluxion_runtime.resources import py_queue
15
+
16
+ _RACE_TIMEOUT_SECONDS = 0.25
17
+
18
+
19
+ def _run_concurrently(worker_count: int, worker) -> list[object]:
20
+ start = threading.Barrier(worker_count)
21
+
22
+ def run(index: int) -> object:
23
+ start.wait()
24
+ return worker(index)
25
+
26
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
27
+ return list(executor.map(run, range(worker_count)))
28
+
29
+
30
+ def _bundle(step_count: int) -> dict[str, Any]:
31
+ return {
32
+ "work_id": "py-race",
33
+ "steps": [
34
+ {
35
+ "step_id": f"s{index}",
36
+ "segment_id": f"g{index}",
37
+ "checksum": f"c{index}",
38
+ "token_estimate": 10,
39
+ "content": f"segment {index}",
40
+ "status": "queued",
41
+ "result": "",
42
+ "error": "",
43
+ }
44
+ for index in range(step_count)
45
+ ],
46
+ }
47
+
48
+
49
+ def _store_payload(store_dir: Path, **payload: Any) -> dict[str, Any]:
50
+ return {"store_dir": str(store_dir), **payload}
51
+
52
+
53
+ def _dispatch_bundle_path(store_dir: Path, work_id: str) -> Path:
54
+ return store_dir / "dispatch" / f"{work_id}.json"
55
+
56
+
57
+ def _read_dispatch_bundle(store_dir: Path, work_id: str) -> dict[str, Any]:
58
+ return json.loads(_dispatch_bundle_path(store_dir, work_id).read_text(encoding="utf-8"))
59
+
60
+
61
+ def _stall_bundle_writes(monkeypatch: pytest.MonkeyPatch) -> None:
62
+ original_write = py_queue._write_atomic
63
+ write_barrier = threading.Barrier(2)
64
+
65
+ def write(path: Path, payload: dict[str, Any]) -> None:
66
+ with suppress(threading.BrokenBarrierError):
67
+ write_barrier.wait(timeout=_RACE_TIMEOUT_SECONDS)
68
+ original_write(path, payload)
69
+
70
+ monkeypatch.setattr(py_queue, "_write_atomic", write)
71
+
72
+
73
+ def test_python_queue_concurrent_next_steps_do_not_claim_same_step(
74
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
75
+ ) -> None:
76
+ """Python fallback queue-next serializes JSON bundle updates."""
77
+ store_dir = tmp_path / "queue"
78
+ py_queue.run("persist", _store_payload(store_dir, work_id="py-race", bundle=_bundle(2)))
79
+ _stall_bundle_writes(monkeypatch)
80
+
81
+ results = _run_concurrently(2, lambda _index: py_queue.run("next", _store_payload(store_dir, work_id="py-race")))
82
+ step_ids = [str(result["step"]["step_id"]) for result in results if result["ready"]]
83
+
84
+ assert len(step_ids) == 2
85
+ assert len(set(step_ids)) == 2
86
+ statuses = [step["status"] for step in _read_dispatch_bundle(store_dir, "py-race")["steps"]]
87
+ assert statuses.count("running") == 2
88
+
89
+
90
+ def test_python_queue_concurrent_record_steps_preserve_both_updates(
91
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
92
+ ) -> None:
93
+ """Python fallback queue-record serializes JSON bundle updates."""
94
+ store_dir = tmp_path / "queue"
95
+ py_queue.run("persist", _store_payload(store_dir, work_id="py-race", bundle=_bundle(2)))
96
+ step_ids = [
97
+ str(py_queue.run("next", _store_payload(store_dir, work_id="py-race"))["step"]["step_id"]),
98
+ str(py_queue.run("next", _store_payload(store_dir, work_id="py-race"))["step"]["step_id"]),
99
+ ]
100
+ _stall_bundle_writes(monkeypatch)
101
+
102
+ _run_concurrently(
103
+ 2,
104
+ lambda index: py_queue.run(
105
+ "record",
106
+ _store_payload(
107
+ store_dir,
108
+ work_id="py-race",
109
+ step_id=step_ids[index],
110
+ result=f"done:{index}",
111
+ ),
112
+ ),
113
+ )
114
+
115
+ steps = {step["step_id"]: step for step in _read_dispatch_bundle(store_dir, "py-race")["steps"]}
116
+ assert steps[step_ids[0]]["result"] == "done:0"
117
+ assert steps[step_ids[1]]["result"] == "done:1"
118
+
119
+
120
+ def test_python_queue_concurrent_dequeue_serializes_select_update(
121
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
122
+ ) -> None:
123
+ """Python fallback dequeue uses a transaction so two workers cannot claim one row."""
124
+ store_dir = tmp_path / "queue"
125
+ for index in range(2):
126
+ py_queue.run(
127
+ "enqueue",
128
+ _store_payload(store_dir, work_id=f"w{index}", prompt=f"prompt {index}", priority=index),
129
+ )
130
+
131
+ original_open_db = py_queue._open_db
132
+ select_barrier = threading.Barrier(2)
133
+
134
+ class SlowConnection:
135
+ def __init__(self, conn) -> None:
136
+ self._conn = conn
137
+
138
+ def __enter__(self):
139
+ self._conn.__enter__()
140
+ return self
141
+
142
+ def __exit__(self, exc_type, exc, tb) -> bool | None:
143
+ return self._conn.__exit__(exc_type, exc, tb)
144
+
145
+ def execute(self, sql: str, parameters: tuple[object, ...] = ()):
146
+ if "SELECT work_id, prompt, surface, priority, metadata_json FROM work_queue" in sql:
147
+ with suppress(threading.BrokenBarrierError):
148
+ select_barrier.wait(timeout=_RACE_TIMEOUT_SECONDS)
149
+ return self._conn.execute(sql, parameters)
150
+
151
+ def open_db(path: Path) -> SlowConnection:
152
+ return SlowConnection(original_open_db(path))
153
+
154
+ monkeypatch.setattr(py_queue, "_open_db", open_db)
155
+
156
+ results = _run_concurrently(2, lambda _index: py_queue.run("dequeue", _store_payload(store_dir)))
157
+ work_ids = [str(result["item"]["work_id"]) for result in results if result["ready"]]
158
+
159
+ assert len(work_ids) == 2
160
+ assert len(set(work_ids)) == 2