local-coding-agent 1.1.0__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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Model discovery, profile resolution and availability checks for the Desktop Harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ...model_scanner import get_model_registry
|
|
11
|
+
from ...ollama_adapter import OllamaError, OpenAICompatibleClient, classify_backend_error as _classify_backend_error
|
|
12
|
+
from ...profiles import ModelProfile, get_profile
|
|
13
|
+
|
|
14
|
+
# NOTE: `discover_local_ollama_models` and `profile_model_is_available` are
|
|
15
|
+
# imported at call time from the `server` package (re-exports) so that tests
|
|
16
|
+
# that patch `local_coding_agent.desktop.server.<name>` take effect.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def discover_local_ollama_models() -> list[str]:
|
|
20
|
+
"""Query live Ollama API and scan local disk manifests for installed models."""
|
|
21
|
+
models: list[str] = []
|
|
22
|
+
# 1. Probe live endpoint
|
|
23
|
+
try:
|
|
24
|
+
req = urllib.request.Request("http://127.0.0.1:11434/api/tags")
|
|
25
|
+
with urllib.request.urlopen(req, timeout=0.3) as resp:
|
|
26
|
+
if resp.status == 200:
|
|
27
|
+
tags_data = json.loads(resp.read().decode("utf-8"))
|
|
28
|
+
for m in tags_data.get("models", []):
|
|
29
|
+
if isinstance(m, dict) and "name" in m:
|
|
30
|
+
models.append(m["name"])
|
|
31
|
+
if models:
|
|
32
|
+
return sorted(list(set(models)))
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
# 2. Disk manifests inspection in ~/.ollama/models/manifests
|
|
37
|
+
manifest_root = Path.home() / ".ollama" / "models" / "manifests"
|
|
38
|
+
if manifest_root.exists():
|
|
39
|
+
try:
|
|
40
|
+
for reg in manifest_root.iterdir():
|
|
41
|
+
if reg.is_dir():
|
|
42
|
+
for user_or_lib in reg.iterdir():
|
|
43
|
+
if user_or_lib.is_dir():
|
|
44
|
+
for model_dir in user_or_lib.iterdir():
|
|
45
|
+
if model_dir.is_dir():
|
|
46
|
+
for tag_file in model_dir.iterdir():
|
|
47
|
+
if tag_file.is_file():
|
|
48
|
+
prefix = "" if user_or_lib.name == "library" else f"{user_or_lib.name}/"
|
|
49
|
+
models.append(f"{prefix}{model_dir.name}:{tag_file.name}")
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
return sorted(list(set(models)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_model_profile(name: str, registry: Any = None) -> ModelProfile:
|
|
57
|
+
"""Resolve a profile by known profile name, installed Ollama tag, or discovered GGUF model."""
|
|
58
|
+
if registry is None:
|
|
59
|
+
registry = get_model_registry()
|
|
60
|
+
clean_name = name.strip()
|
|
61
|
+
|
|
62
|
+
# 1. Exact known profile (carries correct provider/endpoint)
|
|
63
|
+
try:
|
|
64
|
+
return get_profile(clean_name)
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# 2. Installed Ollama tag
|
|
69
|
+
from ..server import discover_local_ollama_models
|
|
70
|
+
ollama_models = discover_local_ollama_models()
|
|
71
|
+
if clean_name in ollama_models:
|
|
72
|
+
matched = clean_name
|
|
73
|
+
return ModelProfile(
|
|
74
|
+
name=clean_name,
|
|
75
|
+
model=matched,
|
|
76
|
+
provider="ollama",
|
|
77
|
+
endpoint="http://127.0.0.1:11434",
|
|
78
|
+
num_ctx=8192,
|
|
79
|
+
)
|
|
80
|
+
# An untagged local name is an alias only when exactly one served tag has
|
|
81
|
+
# that basename. Never select a similarly named namespace/tag implicitly.
|
|
82
|
+
if ":" not in clean_name and "/" not in clean_name:
|
|
83
|
+
aliases = [model for model in ollama_models if model.split(":", 1)[0] == clean_name]
|
|
84
|
+
if len(aliases) == 1:
|
|
85
|
+
matched = aliases[0]
|
|
86
|
+
else:
|
|
87
|
+
matched = None
|
|
88
|
+
else:
|
|
89
|
+
matched = None
|
|
90
|
+
if matched is not None:
|
|
91
|
+
return ModelProfile(
|
|
92
|
+
name=clean_name,
|
|
93
|
+
model=matched,
|
|
94
|
+
provider="ollama",
|
|
95
|
+
endpoint="http://127.0.0.1:11434",
|
|
96
|
+
num_ctx=8192,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# 3. Discovered GGUF model — exact stable path-id wins over ambiguous basename
|
|
100
|
+
for discovered in registry.get_models(auto_scan=True):
|
|
101
|
+
if clean_name == discovered.to_dict().get("id"):
|
|
102
|
+
return ModelProfile(
|
|
103
|
+
name=clean_name,
|
|
104
|
+
model=discovered.display_name,
|
|
105
|
+
provider="openai",
|
|
106
|
+
endpoint="http://127.0.0.1:8080",
|
|
107
|
+
num_ctx=8192,
|
|
108
|
+
)
|
|
109
|
+
for discovered in registry.get_models(auto_scan=True):
|
|
110
|
+
if clean_name.lower() in (discovered.name.lower(), discovered.display_name.lower()):
|
|
111
|
+
return ModelProfile(
|
|
112
|
+
name=clean_name,
|
|
113
|
+
model=discovered.display_name,
|
|
114
|
+
provider="openai",
|
|
115
|
+
endpoint="http://127.0.0.1:8080",
|
|
116
|
+
num_ctx=8192,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# 4. Fallback to Ollama profile using name as model
|
|
120
|
+
return ModelProfile(
|
|
121
|
+
name=clean_name,
|
|
122
|
+
model=clean_name,
|
|
123
|
+
provider="ollama",
|
|
124
|
+
endpoint="http://127.0.0.1:11434",
|
|
125
|
+
num_ctx=8192,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def profile_model_is_available(profile: ModelProfile) -> bool:
|
|
130
|
+
"""Return True if the profile's model is actually installed/available on a live backend."""
|
|
131
|
+
from ..server import discover_local_ollama_models
|
|
132
|
+
try:
|
|
133
|
+
if profile.provider == "ollama":
|
|
134
|
+
ollama_models = discover_local_ollama_models()
|
|
135
|
+
target = profile.model
|
|
136
|
+
if target in ollama_models:
|
|
137
|
+
return True
|
|
138
|
+
if ":" in target or "/" in target:
|
|
139
|
+
return False
|
|
140
|
+
return sum(model.split(":", 1)[0] == target for model in ollama_models) == 1
|
|
141
|
+
if profile.provider == "openai":
|
|
142
|
+
try:
|
|
143
|
+
avail = OpenAICompatibleClient(profile).available_models()
|
|
144
|
+
live_names = {m["name"] for m in avail.get("models", []) if isinstance(m, dict) and "name" in m}
|
|
145
|
+
if profile.model in live_names:
|
|
146
|
+
return True
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
gguf_names = {m.display_name for m in get_model_registry().get_models(auto_scan=True)}
|
|
150
|
+
gguf_names.update(m.name for m in get_model_registry().get_models(auto_scan=True))
|
|
151
|
+
return profile.model in gguf_names
|
|
152
|
+
return False
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def select_available_profile(preferred: str) -> str:
|
|
158
|
+
"""Return a profile name whose model is actually installed, falling back to discovered models."""
|
|
159
|
+
from ..server import discover_local_ollama_models, profile_model_is_available
|
|
160
|
+
try:
|
|
161
|
+
if _is_known_profile(preferred) and profile_model_is_available(resolve_model_profile(preferred)):
|
|
162
|
+
return preferred
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
try:
|
|
166
|
+
ollama_models = discover_local_ollama_models()
|
|
167
|
+
if ollama_models:
|
|
168
|
+
return ollama_models[0]
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
discovered = get_model_registry().get_models(auto_scan=True)
|
|
173
|
+
if discovered:
|
|
174
|
+
return discovered[0].display_name
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
return preferred
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _is_known_profile(name: str) -> bool:
|
|
181
|
+
try:
|
|
182
|
+
get_profile(name)
|
|
183
|
+
return True
|
|
184
|
+
except ValueError:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_discovered_gguf(name: str, registry: Any = None) -> dict[str, Any] | None:
|
|
189
|
+
"""Return the registry entry (incl. on-disk path) for a discovered GGUF model.
|
|
190
|
+
|
|
191
|
+
Resolution order: exact stable path-id first (two models may share a
|
|
192
|
+
basename), then legacy case-insensitive basename/display-name first-match.
|
|
193
|
+
"""
|
|
194
|
+
if registry is None:
|
|
195
|
+
registry = get_model_registry()
|
|
196
|
+
clean = name.strip()
|
|
197
|
+
discovered_list = registry.get_models(auto_scan=True)
|
|
198
|
+
for discovered in discovered_list:
|
|
199
|
+
if clean == discovered.to_dict().get("id"):
|
|
200
|
+
return discovered.to_dict()
|
|
201
|
+
for discovered in discovered_list:
|
|
202
|
+
if clean.lower() in (discovered.name.lower(), discovered.display_name.lower()):
|
|
203
|
+
return discovered.to_dict()
|
|
204
|
+
return None
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Desktop Harness embedded HTTP server with persistent storage and process orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from http.server import ThreadingHTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ...stats import DelegationStats
|
|
17
|
+
from ...task import TaskEnvelope
|
|
18
|
+
from ._handlers import (
|
|
19
|
+
DesktopRequestHandler,
|
|
20
|
+
build_queue_controller,
|
|
21
|
+
detect_relevant_files,
|
|
22
|
+
detect_test_checks,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# How often the background task worker polls the store for queued records.
|
|
26
|
+
_TASK_POLL_INTERVAL = 0.3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PersistenceError(RuntimeError):
|
|
30
|
+
"""A desktop state file could not be read or durably replaced."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DesktopServer:
|
|
34
|
+
"""Desktop Harness embedded HTTP server with persistent storage and process orchestration."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
host: str = "127.0.0.1",
|
|
39
|
+
port: int = 0,
|
|
40
|
+
workspace: str | Path = ".",
|
|
41
|
+
default_profile: str = "qwen2.5-coder",
|
|
42
|
+
stats: DelegationStats | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.host = host
|
|
45
|
+
self.port = port
|
|
46
|
+
# Hard security boundary: the mutation token is injected into the served
|
|
47
|
+
# HTML, so a non-loopback bind would hand it to every LAN peer.
|
|
48
|
+
self._require_loopback(host)
|
|
49
|
+
self.workspace = str(Path(workspace).resolve())
|
|
50
|
+
self.default_profile = default_profile
|
|
51
|
+
self.mutation_token = secrets.token_urlsafe(32)
|
|
52
|
+
self.stats = stats or DelegationStats()
|
|
53
|
+
self.started_at = time.monotonic()
|
|
54
|
+
self.spawned_processes: dict[str, subprocess.Popen[Any]] = {}
|
|
55
|
+
# ponytail: single-slot "last apply" tracking — a second apply overwrites
|
|
56
|
+
# the first, so rollback undoes only the most recent apply, not a full
|
|
57
|
+
# undo stack.
|
|
58
|
+
self.last_applied_files: list[str] = []
|
|
59
|
+
self.last_applied_patch: str = ""
|
|
60
|
+
self.apply_lock = threading.Lock()
|
|
61
|
+
# ponytail: monotonically-increasing counter drives the hybrid classifier's
|
|
62
|
+
# periodic re-evaluation cadence (classify_mode counter % n_every == 0).
|
|
63
|
+
self.hybrid_counter = 0
|
|
64
|
+
# Context window used when spawning llama-server (-c). Overridable per
|
|
65
|
+
# chat/model-load request; the running server must be relaunched to
|
|
66
|
+
# apply a change, so remember what is currently loaded.
|
|
67
|
+
self.llama_num_ctx = 8192
|
|
68
|
+
self.llama_gguf_path: str | None = None
|
|
69
|
+
self.llama_gguf_label: str | None = None
|
|
70
|
+
# Read back from llama-server /props after launch: what the server
|
|
71
|
+
# actually serves may be clamped to the model's native context length.
|
|
72
|
+
self.llama_effective_ctx: int | None = None
|
|
73
|
+
self.sessions_file = Path(self.workspace) / ".local_agent_sessions.json"
|
|
74
|
+
self.sessions_lock = threading.Lock()
|
|
75
|
+
# Background task queue (R23): persisted proposal-only delegation jobs.
|
|
76
|
+
self.tasks_file = Path(self.workspace) / ".local_agent_tasks.json"
|
|
77
|
+
self.task_queue_lock = threading.Lock()
|
|
78
|
+
self._task_events: dict[str, threading.Event] = {}
|
|
79
|
+
self._task_stop = threading.Event()
|
|
80
|
+
self._task_thread: threading.Thread | None = None
|
|
81
|
+
# Test seam: when set, the worker builds its controller through
|
|
82
|
+
# controller_factory(profile, workspace, cancel_event=...) -> object
|
|
83
|
+
# with .run(task). When None it mirrors /api/delegate's construction.
|
|
84
|
+
self.controller_factory: Any = None
|
|
85
|
+
self._recover_interrupted_tasks()
|
|
86
|
+
self._httpd = ThreadingHTTPServer((host, port), DesktopRequestHandler)
|
|
87
|
+
self._httpd.desktop_server = self # type: ignore[attr-defined]
|
|
88
|
+
self._thread: threading.Thread | None = None
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _require_loopback(host: str) -> None:
|
|
92
|
+
candidate = "127.0.0.1" if host == "localhost" else host
|
|
93
|
+
try:
|
|
94
|
+
addr = ipaddress.ip_address(candidate)
|
|
95
|
+
except ValueError as error:
|
|
96
|
+
raise ValueError(f"Desktop server host must be a loopback address, got {host!r}") from error
|
|
97
|
+
if not addr.is_loopback:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Desktop server refuses non-loopback bind ({host}); the UI page embeds "
|
|
100
|
+
"the mutation token and must stay local-only"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def actual_port(self) -> int:
|
|
105
|
+
return self._httpd.server_address[1]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def url(self) -> str:
|
|
109
|
+
return f"http://{self.host}:{self.actual_port}"
|
|
110
|
+
|
|
111
|
+
def load_sessions(self) -> list[dict[str, Any]]:
|
|
112
|
+
with self.sessions_lock:
|
|
113
|
+
return self._load_sessions_unlocked()
|
|
114
|
+
|
|
115
|
+
def _load_sessions_unlocked(self) -> list[dict[str, Any]]:
|
|
116
|
+
if not self.sessions_file.exists():
|
|
117
|
+
return []
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(self.sessions_file.read_text(encoding="utf-8-sig"))
|
|
120
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
121
|
+
raise PersistenceError(f"Cannot read session history without data loss: {error}") from error
|
|
122
|
+
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
|
|
123
|
+
raise PersistenceError("Session history must be a JSON array of objects")
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
def save_session(self, session: dict[str, Any]) -> None:
|
|
127
|
+
with self.sessions_lock:
|
|
128
|
+
sessions = self._load_sessions_unlocked()
|
|
129
|
+
sessions = [s for s in sessions if s.get("id") != session.get("id")]
|
|
130
|
+
sessions.insert(0, session)
|
|
131
|
+
self._atomic_write_json(self.sessions_file, sessions[:50])
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _atomic_write_json(path: Path, value: Any) -> None:
|
|
135
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
136
|
+
try:
|
|
137
|
+
temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
138
|
+
temporary.replace(path)
|
|
139
|
+
except Exception as error:
|
|
140
|
+
try:
|
|
141
|
+
temporary.unlink()
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
raise PersistenceError(f"Cannot atomically write {path.name}: {error}") from error
|
|
145
|
+
|
|
146
|
+
# ---- Background task queue store (newest first, capped at 100) ----
|
|
147
|
+
|
|
148
|
+
def load_tasks(self) -> list[dict[str, Any]]:
|
|
149
|
+
if self.tasks_file.exists():
|
|
150
|
+
try:
|
|
151
|
+
data = json.loads(self.tasks_file.read_text(encoding="utf-8"))
|
|
152
|
+
if isinstance(data, list):
|
|
153
|
+
return data
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
def _write_tasks(self, tasks: list[dict[str, Any]]) -> None:
|
|
159
|
+
try:
|
|
160
|
+
self.tasks_file.write_text(json.dumps(tasks[:100], indent=2, ensure_ascii=False), encoding="utf-8")
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
def save_task(self, task: dict[str, Any]) -> None:
|
|
165
|
+
tasks = [t for t in self.load_tasks() if t.get("id") != task.get("id")]
|
|
166
|
+
tasks.insert(0, task)
|
|
167
|
+
self._write_tasks(tasks)
|
|
168
|
+
|
|
169
|
+
def get_task(self, task_id: str) -> dict[str, Any] | None:
|
|
170
|
+
return next((t for t in self.load_tasks() if t.get("id") == task_id), None)
|
|
171
|
+
|
|
172
|
+
def update_task(self, task_id: str, **fields: Any) -> dict[str, Any] | None:
|
|
173
|
+
"""Patch a stored record; callers hold ``task_queue_lock``."""
|
|
174
|
+
tasks = self.load_tasks()
|
|
175
|
+
updated: dict[str, Any] | None = None
|
|
176
|
+
for index, record in enumerate(tasks):
|
|
177
|
+
if record.get("id") == task_id:
|
|
178
|
+
record.update(fields)
|
|
179
|
+
tasks[index] = record
|
|
180
|
+
updated = record
|
|
181
|
+
break
|
|
182
|
+
if updated is not None:
|
|
183
|
+
self._write_tasks(tasks)
|
|
184
|
+
return updated
|
|
185
|
+
|
|
186
|
+
def _recover_interrupted_tasks(self) -> None:
|
|
187
|
+
"""Fail records left 'running' by a previous process.
|
|
188
|
+
|
|
189
|
+
Queued tasks stay queued and are picked up on start() (persistence
|
|
190
|
+
gives this for free); a running Controller cannot survive its process,
|
|
191
|
+
so its record fails honestly instead of hanging forever.
|
|
192
|
+
"""
|
|
193
|
+
tasks = self.load_tasks()
|
|
194
|
+
stuck = [t for t in tasks if t.get("status") == "running"]
|
|
195
|
+
if not stuck:
|
|
196
|
+
return
|
|
197
|
+
for record in stuck:
|
|
198
|
+
record["status"] = "failed"
|
|
199
|
+
record["finished_at"] = time.time()
|
|
200
|
+
record["error"] = "server restarted while the task was running"
|
|
201
|
+
self._write_tasks(tasks)
|
|
202
|
+
|
|
203
|
+
# ---- Background task worker ----
|
|
204
|
+
|
|
205
|
+
def _task_worker_loop(self) -> None:
|
|
206
|
+
"""Sequential background executor: one queued task at a time.
|
|
207
|
+
|
|
208
|
+
# ponytail: sequential queue — concurrent pool only when batch
|
|
209
|
+
throughput measurably matters. Survives any exception by failing the
|
|
210
|
+
record and continuing. Polls every 0.3s; queued tasks persist across
|
|
211
|
+
restarts, so anything left queued is simply picked up next start().
|
|
212
|
+
"""
|
|
213
|
+
while not self._task_stop.wait(_TASK_POLL_INTERVAL):
|
|
214
|
+
with self.task_queue_lock:
|
|
215
|
+
queued = [t for t in self.load_tasks() if t.get("status") == "queued"]
|
|
216
|
+
record = dict(queued[-1]) if queued else None # newest-first -> last is oldest
|
|
217
|
+
if record is None:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
self._execute_task(record)
|
|
221
|
+
except Exception as error: # never let the worker thread die
|
|
222
|
+
with self.task_queue_lock:
|
|
223
|
+
self.update_task(record["id"], status="failed", finished_at=time.time(), error=str(error))
|
|
224
|
+
|
|
225
|
+
def _execute_task(self, record: dict[str, Any]) -> None:
|
|
226
|
+
"""Run one delegated task proposal-only and store the outcome."""
|
|
227
|
+
task_id = record["id"]
|
|
228
|
+
cancel_event = threading.Event()
|
|
229
|
+
with self.task_queue_lock:
|
|
230
|
+
current = self.get_task(task_id)
|
|
231
|
+
if current is None or current.get("status") != "queued":
|
|
232
|
+
return # cancelled between pick-up and execution
|
|
233
|
+
self._task_events[task_id] = cancel_event
|
|
234
|
+
self.update_task(task_id, status="running", started_at=time.time())
|
|
235
|
+
try:
|
|
236
|
+
files = [str(f) for f in (record.get("files") or [])]
|
|
237
|
+
if not files:
|
|
238
|
+
files = detect_relevant_files(self.workspace, record["goal"])
|
|
239
|
+
checks = [str(c) for c in (record.get("checks") or [])]
|
|
240
|
+
if not checks:
|
|
241
|
+
checks = detect_test_checks(self.workspace)
|
|
242
|
+
if not files:
|
|
243
|
+
with self.task_queue_lock:
|
|
244
|
+
self.update_task(
|
|
245
|
+
task_id,
|
|
246
|
+
status="failed",
|
|
247
|
+
finished_at=time.time(),
|
|
248
|
+
error={
|
|
249
|
+
"kind": "needs_context",
|
|
250
|
+
"message": "No unambiguous existing file scope was provided for this build task.",
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
return
|
|
254
|
+
envelope = TaskEnvelope(
|
|
255
|
+
id=task_id,
|
|
256
|
+
goal=record["goal"],
|
|
257
|
+
files=tuple(files),
|
|
258
|
+
checks=tuple(checks),
|
|
259
|
+
)
|
|
260
|
+
factory = self.controller_factory or build_queue_controller
|
|
261
|
+
controller = factory(
|
|
262
|
+
record.get("profile") or self.default_profile,
|
|
263
|
+
self.workspace,
|
|
264
|
+
cancel_event=cancel_event,
|
|
265
|
+
)
|
|
266
|
+
result = controller.run(envelope) or {}
|
|
267
|
+
if cancel_event.is_set():
|
|
268
|
+
final_status = "cancelled"
|
|
269
|
+
elif result.get("status") == "accepted":
|
|
270
|
+
final_status = "accepted"
|
|
271
|
+
else:
|
|
272
|
+
final_status = "failed"
|
|
273
|
+
error = result.get("error") or ""
|
|
274
|
+
if final_status == "failed" and not error:
|
|
275
|
+
error = str(result.get("summary") or f"task ended with status {result.get('status')}")
|
|
276
|
+
with self.task_queue_lock:
|
|
277
|
+
self.update_task(
|
|
278
|
+
task_id,
|
|
279
|
+
status=final_status,
|
|
280
|
+
finished_at=time.time(),
|
|
281
|
+
files=files,
|
|
282
|
+
summary=str(result.get("summary") or ""),
|
|
283
|
+
patch=str(result.get("patch") or ""),
|
|
284
|
+
checks_results=result.get("checks") if isinstance(result.get("checks"), list) else [],
|
|
285
|
+
error=error,
|
|
286
|
+
)
|
|
287
|
+
except Exception as error:
|
|
288
|
+
status = "cancelled" if cancel_event.is_set() else "failed"
|
|
289
|
+
with self.task_queue_lock:
|
|
290
|
+
self.update_task(task_id, status=status, finished_at=time.time(), error=str(error))
|
|
291
|
+
finally:
|
|
292
|
+
with self.task_queue_lock:
|
|
293
|
+
self._task_events.pop(task_id, None)
|
|
294
|
+
|
|
295
|
+
def start(self) -> None:
|
|
296
|
+
if self._thread is not None:
|
|
297
|
+
return
|
|
298
|
+
self._thread = threading.Thread(
|
|
299
|
+
target=self._httpd.serve_forever,
|
|
300
|
+
name="local-agent-desktop-http",
|
|
301
|
+
daemon=True,
|
|
302
|
+
)
|
|
303
|
+
self._thread.start()
|
|
304
|
+
self._task_stop.clear()
|
|
305
|
+
self._task_thread = threading.Thread(
|
|
306
|
+
target=self._task_worker_loop,
|
|
307
|
+
name="local-agent-desktop-tasks",
|
|
308
|
+
daemon=True,
|
|
309
|
+
)
|
|
310
|
+
self._task_thread.start()
|
|
311
|
+
|
|
312
|
+
def stop(self) -> None:
|
|
313
|
+
# Wake/join the task worker briefly: a running Controller finishes in
|
|
314
|
+
# its daemon thread (never killed mid-write); queued tasks remain
|
|
315
|
+
# queued on restart via the persisted store.
|
|
316
|
+
self._task_stop.set()
|
|
317
|
+
if self._task_thread is not None:
|
|
318
|
+
self._task_thread.join(timeout=2.0)
|
|
319
|
+
self._task_thread = None
|
|
320
|
+
|
|
321
|
+
for name, proc in list(self.spawned_processes.items()):
|
|
322
|
+
try:
|
|
323
|
+
if os.name == "nt":
|
|
324
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True, check=False)
|
|
325
|
+
else:
|
|
326
|
+
proc.terminate()
|
|
327
|
+
proc.wait(timeout=2.0)
|
|
328
|
+
except Exception:
|
|
329
|
+
try:
|
|
330
|
+
proc.kill()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
self.spawned_processes.clear()
|
|
334
|
+
|
|
335
|
+
if self._thread is None:
|
|
336
|
+
return
|
|
337
|
+
self._httpd.shutdown()
|
|
338
|
+
self._httpd.server_close()
|
|
339
|
+
self._thread.join(timeout=3.0)
|
|
340
|
+
self._thread = None
|
|
341
|
+
|
|
342
|
+
def __enter__(self) -> DesktopServer:
|
|
343
|
+
self.start()
|
|
344
|
+
return self
|
|
345
|
+
|
|
346
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
347
|
+
self.stop()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Real hardware GPU telemetry for the Desktop Harness (from nvidia-smi)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_nvidia_gpu_telemetry() -> dict[str, Any] | None:
|
|
11
|
+
"""Query live single-device GPU metrics directly from ``nvidia-smi``.
|
|
12
|
+
|
|
13
|
+
The VRAM fitter is intentionally single-device. A multi-GPU response is
|
|
14
|
+
therefore reported as unsupported instead of silently selecting the first
|
|
15
|
+
row and applying that device's capacity to an unspecified backend.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
res = subprocess.run(
|
|
19
|
+
[
|
|
20
|
+
"nvidia-smi",
|
|
21
|
+
"--query-gpu=index,uuid,memory.used,memory.total,utilization.gpu,name,temperature.gpu",
|
|
22
|
+
"--format=csv,noheader,nounits",
|
|
23
|
+
],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=False,
|
|
27
|
+
timeout=1.5,
|
|
28
|
+
)
|
|
29
|
+
if res.returncode != 0 or not res.stdout.strip():
|
|
30
|
+
detail = (res.stderr or "nvidia-smi returned no telemetry").strip()
|
|
31
|
+
return _unavailable(detail)
|
|
32
|
+
lines = [line for line in res.stdout.splitlines() if line.strip()]
|
|
33
|
+
if len(lines) != 1:
|
|
34
|
+
return _unavailable(
|
|
35
|
+
f"nvidia-smi reported {len(lines)} GPUs; single-device VRAM attribution is unsupported",
|
|
36
|
+
status="unsupported",
|
|
37
|
+
)
|
|
38
|
+
parts = [p.strip() for p in lines[0].split(",")]
|
|
39
|
+
if len(parts) < 7:
|
|
40
|
+
return _unavailable("nvidia-smi returned an incomplete telemetry row")
|
|
41
|
+
index, uuid, used_raw, total_raw, util_raw, name, temp_raw = parts[:7]
|
|
42
|
+
used_mb = float(used_raw)
|
|
43
|
+
total_mb = float(total_raw)
|
|
44
|
+
util_gpu = float(util_raw)
|
|
45
|
+
temp_c = float(temp_raw)
|
|
46
|
+
if not all(math.isfinite(value) for value in (used_mb, total_mb, util_gpu, temp_c)):
|
|
47
|
+
return _unavailable("nvidia-smi returned non-finite telemetry")
|
|
48
|
+
if total_mb <= 0 or used_mb < 0 or used_mb > total_mb:
|
|
49
|
+
return _unavailable("nvidia-smi returned invalid VRAM totals")
|
|
50
|
+
return {
|
|
51
|
+
"status": "ok",
|
|
52
|
+
"gpu_index": index,
|
|
53
|
+
"gpu_uuid": uuid,
|
|
54
|
+
"gpu_name": name,
|
|
55
|
+
"used_mb": round(used_mb, 1),
|
|
56
|
+
"total_mb": round(total_mb, 1),
|
|
57
|
+
"used_gb": round(used_mb / 1024, 1),
|
|
58
|
+
"total_gb": round(total_mb / 1024, 1),
|
|
59
|
+
"percent": round((used_mb / total_mb) * 100, 1),
|
|
60
|
+
"utilization_pct": round(util_gpu, 1),
|
|
61
|
+
"temp_c": round(temp_c, 1),
|
|
62
|
+
"source": "nvidia-smi",
|
|
63
|
+
}
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
return _unavailable("nvidia-smi telemetry timed out")
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return _unavailable("nvidia-smi executable is unavailable")
|
|
68
|
+
except (OSError, ValueError) as error:
|
|
69
|
+
return _unavailable(f"nvidia-smi telemetry is invalid: {error}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unavailable(error: str, *, status: str = "unavailable") -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"status": status,
|
|
75
|
+
"source": "nvidia-smi",
|
|
76
|
+
"error": error,
|
|
77
|
+
"gpu_index": None,
|
|
78
|
+
"gpu_uuid": None,
|
|
79
|
+
"gpu_name": None,
|
|
80
|
+
"used_mb": None,
|
|
81
|
+
"total_mb": None,
|
|
82
|
+
"used_gb": None,
|
|
83
|
+
"total_gb": None,
|
|
84
|
+
"percent": None,
|
|
85
|
+
"utilization_pct": None,
|
|
86
|
+
"temp_c": None,
|
|
87
|
+
}
|