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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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
+ }