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,516 @@
|
|
|
1
|
+
"""Bounded in-memory worker pool for proposal-only delegations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Deque, Mapping
|
|
15
|
+
|
|
16
|
+
from .service import DelegationRequest
|
|
17
|
+
from .task_store import TaskRecord, TaskStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_TERMINAL_STATES = frozenset({"completed", "failed", "cancelled", "interrupted"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _now_iso() -> str:
|
|
25
|
+
return datetime.now(timezone.utc).isoformat()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExecutionOverload(RuntimeError):
|
|
29
|
+
"""Raised when the shared runtime admission capacity is full."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _ExecutionLease:
|
|
33
|
+
def __init__(self, gate: "SharedExecutionGate") -> None:
|
|
34
|
+
self._gate = gate
|
|
35
|
+
self._released = False
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
def release(self) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
if self._released:
|
|
41
|
+
return
|
|
42
|
+
self._released = True
|
|
43
|
+
self._gate._release()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SharedExecutionGate:
|
|
47
|
+
"""Thread-safe admission and active-slot gate shared by all MCP paths."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, max_workers: int, max_queue: int) -> None:
|
|
50
|
+
if max_workers <= 0:
|
|
51
|
+
raise ValueError("max_workers must be positive")
|
|
52
|
+
if max_queue < 0:
|
|
53
|
+
raise ValueError("max_queue must be non-negative")
|
|
54
|
+
self._slots = threading.Semaphore(max_workers)
|
|
55
|
+
self._capacity = max_workers + max_queue
|
|
56
|
+
self._condition = threading.Condition()
|
|
57
|
+
self._admitted = 0
|
|
58
|
+
|
|
59
|
+
def try_acquire(self) -> _ExecutionLease | None:
|
|
60
|
+
with self._condition:
|
|
61
|
+
if self._admitted >= self._capacity:
|
|
62
|
+
return None
|
|
63
|
+
self._admitted += 1
|
|
64
|
+
return _ExecutionLease(self)
|
|
65
|
+
|
|
66
|
+
def run(self, function, *args):
|
|
67
|
+
lease = self.try_acquire()
|
|
68
|
+
if lease is None:
|
|
69
|
+
raise ExecutionOverload("bounded execution queue is full")
|
|
70
|
+
try:
|
|
71
|
+
return self.run_reserved(lease, function, *args)
|
|
72
|
+
finally:
|
|
73
|
+
lease.release()
|
|
74
|
+
|
|
75
|
+
def run_reserved(self, lease: _ExecutionLease, function, *args):
|
|
76
|
+
"""Run an already-admitted task while holding a shared active slot."""
|
|
77
|
+
|
|
78
|
+
self._slots.acquire()
|
|
79
|
+
try:
|
|
80
|
+
return function(*args)
|
|
81
|
+
finally:
|
|
82
|
+
self._slots.release()
|
|
83
|
+
|
|
84
|
+
def _release(self) -> None:
|
|
85
|
+
with self._condition:
|
|
86
|
+
self._admitted -= 1
|
|
87
|
+
self._condition.notify_all()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class _Job:
|
|
92
|
+
job_id: str
|
|
93
|
+
caller_id: str
|
|
94
|
+
request_key: tuple[str, str, str]
|
|
95
|
+
fingerprint: str
|
|
96
|
+
request: DelegationRequest
|
|
97
|
+
state: str = "queued"
|
|
98
|
+
created_at: str = ""
|
|
99
|
+
updated_at: str = ""
|
|
100
|
+
result: dict[str, Any] | None = None
|
|
101
|
+
cancel_event: threading.Event | None = None
|
|
102
|
+
execution_complete: threading.Event | None = None
|
|
103
|
+
completed: threading.Event | None = None
|
|
104
|
+
execution_lease: _ExecutionLease | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BoundedWorkerPool:
|
|
108
|
+
"""Run service delegations with bounded queue and caller-scoped jobs.
|
|
109
|
+
|
|
110
|
+
This is deliberately an in-memory execution primitive. It provides bounded
|
|
111
|
+
overload behavior and cooperative cancellation for a future task adapter,
|
|
112
|
+
but it does not claim durable persistence or MCP Tasks conformance.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
service: Any,
|
|
118
|
+
*,
|
|
119
|
+
max_workers: int = 1,
|
|
120
|
+
max_queue: int = 16,
|
|
121
|
+
max_completed_jobs: int = 256,
|
|
122
|
+
execution_gate: SharedExecutionGate | None = None,
|
|
123
|
+
task_store: TaskStore | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
if max_workers <= 0:
|
|
126
|
+
raise ValueError("max_workers must be positive")
|
|
127
|
+
if max_queue < 0:
|
|
128
|
+
raise ValueError("max_queue must be non-negative")
|
|
129
|
+
if max_completed_jobs <= 0:
|
|
130
|
+
raise ValueError("max_completed_jobs must be positive")
|
|
131
|
+
self._service = service
|
|
132
|
+
self._max_completed_jobs = max_completed_jobs
|
|
133
|
+
self._condition = threading.Condition()
|
|
134
|
+
self._queue: Deque[str] = deque()
|
|
135
|
+
self._jobs: dict[str, _Job] = {}
|
|
136
|
+
self._idempotency: dict[tuple[str, str, str], str] = {}
|
|
137
|
+
self._max_workers = max_workers
|
|
138
|
+
self._max_queue = max_queue
|
|
139
|
+
self._execution_gate = execution_gate
|
|
140
|
+
self._task_store = task_store
|
|
141
|
+
self._active_workers = 0
|
|
142
|
+
self._stopping = False
|
|
143
|
+
if self._task_store is not None:
|
|
144
|
+
for record in self._task_store.list(limit=self._max_completed_jobs):
|
|
145
|
+
completed_evt = threading.Event()
|
|
146
|
+
completed_evt.set()
|
|
147
|
+
recovered_result = copy.deepcopy(record.result)
|
|
148
|
+
if record.state == "interrupted":
|
|
149
|
+
# The durable store has no request envelope to safely
|
|
150
|
+
# replay after a process restart. Keep the explicit
|
|
151
|
+
# interrupted state, but expose a terminal controller
|
|
152
|
+
# result so pollers cannot wait forever in "working".
|
|
153
|
+
interruption = copy.deepcopy(record.error) or {
|
|
154
|
+
"kind": "process_interrupted",
|
|
155
|
+
"message": "task was interrupted by server restart",
|
|
156
|
+
}
|
|
157
|
+
recovered_result = dict(recovered_result or {})
|
|
158
|
+
recovered_result.update(
|
|
159
|
+
{
|
|
160
|
+
"status": "failed",
|
|
161
|
+
"error": interruption,
|
|
162
|
+
"applied": False,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
job = _Job(
|
|
166
|
+
job_id=record.task_id,
|
|
167
|
+
caller_id=record.caller_id,
|
|
168
|
+
request_key=(record.caller_id, record.workspace_ref, record.request_id),
|
|
169
|
+
fingerprint=record.fingerprint,
|
|
170
|
+
request=None, # type: ignore[arg-type]
|
|
171
|
+
state=record.state,
|
|
172
|
+
created_at=record.created_at,
|
|
173
|
+
updated_at=record.updated_at,
|
|
174
|
+
result=recovered_result,
|
|
175
|
+
cancel_event=threading.Event(),
|
|
176
|
+
execution_complete=threading.Event(),
|
|
177
|
+
completed=completed_evt,
|
|
178
|
+
)
|
|
179
|
+
self._jobs[record.task_id] = job
|
|
180
|
+
self._idempotency[job.request_key] = job.job_id
|
|
181
|
+
|
|
182
|
+
self._workers = [
|
|
183
|
+
threading.Thread(
|
|
184
|
+
target=self._worker_loop,
|
|
185
|
+
name=f"local-agent-worker-{index}",
|
|
186
|
+
daemon=True,
|
|
187
|
+
)
|
|
188
|
+
for index in range(max_workers)
|
|
189
|
+
]
|
|
190
|
+
for worker in self._workers:
|
|
191
|
+
worker.start()
|
|
192
|
+
|
|
193
|
+
def _persist_job(self, job: _Job) -> None:
|
|
194
|
+
if self._task_store is None:
|
|
195
|
+
return
|
|
196
|
+
try:
|
|
197
|
+
record = TaskRecord(
|
|
198
|
+
task_id=job.job_id,
|
|
199
|
+
caller_id=job.caller_id,
|
|
200
|
+
request_id=job.request.request_id if job.request else job.job_id,
|
|
201
|
+
workspace_ref=job.request.workspace_ref if job.request else "",
|
|
202
|
+
model_profile=job.request.model_profile if job.request else "",
|
|
203
|
+
state=job.state,
|
|
204
|
+
created_at=job.created_at,
|
|
205
|
+
updated_at=job.updated_at,
|
|
206
|
+
result=job.result,
|
|
207
|
+
error=job.result.get("error") if isinstance(job.result, dict) else None,
|
|
208
|
+
fingerprint=job.fingerprint,
|
|
209
|
+
)
|
|
210
|
+
self._task_store.save(record)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
def submit(self, caller_id: str, request: DelegationRequest) -> dict[str, Any]:
|
|
215
|
+
"""Queue one proposal-only request or return a bounded policy failure."""
|
|
216
|
+
|
|
217
|
+
if not isinstance(caller_id, str) or not caller_id.strip():
|
|
218
|
+
return self._failure("invalid_caller", "caller_id must be a non-empty string")
|
|
219
|
+
if not isinstance(request, DelegationRequest):
|
|
220
|
+
return self._failure("invalid_request", "request must be a DelegationRequest")
|
|
221
|
+
|
|
222
|
+
request_key = (caller_id, request.workspace_ref, request.request_id)
|
|
223
|
+
fingerprint = self._fingerprint(request)
|
|
224
|
+
with self._condition:
|
|
225
|
+
existing_id = self._idempotency.get(request_key)
|
|
226
|
+
if existing_id is not None:
|
|
227
|
+
existing = self._jobs.get(existing_id)
|
|
228
|
+
if existing is not None:
|
|
229
|
+
if existing.fingerprint and existing.fingerprint != fingerprint:
|
|
230
|
+
return self._failure(
|
|
231
|
+
"idempotency_conflict",
|
|
232
|
+
"request_id was already used with a different request payload",
|
|
233
|
+
)
|
|
234
|
+
return self._snapshot(existing)
|
|
235
|
+
self._idempotency.pop(request_key, None)
|
|
236
|
+
|
|
237
|
+
if self._stopping:
|
|
238
|
+
return self._failure("pool_shutdown", "worker pool is shutting down")
|
|
239
|
+
if self._active_workers + len(self._queue) >= self._max_workers + self._max_queue:
|
|
240
|
+
return self._failure(
|
|
241
|
+
"queue_overload",
|
|
242
|
+
f"worker capacity is full at max_workers={self._max_workers}, "
|
|
243
|
+
f"max_queue={self._max_queue}",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
execution_lease = self._execution_gate.try_acquire() if self._execution_gate is not None else None
|
|
247
|
+
if self._execution_gate is not None and execution_lease is None:
|
|
248
|
+
return self._failure(
|
|
249
|
+
"queue_overload",
|
|
250
|
+
"shared execution capacity is full",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
job = _Job(
|
|
254
|
+
job_id=uuid.uuid4().hex,
|
|
255
|
+
caller_id=caller_id,
|
|
256
|
+
request_key=request_key,
|
|
257
|
+
fingerprint=fingerprint,
|
|
258
|
+
request=request,
|
|
259
|
+
created_at=_now_iso(),
|
|
260
|
+
updated_at=_now_iso(),
|
|
261
|
+
cancel_event=threading.Event(),
|
|
262
|
+
execution_complete=threading.Event(),
|
|
263
|
+
completed=threading.Event(),
|
|
264
|
+
execution_lease=execution_lease,
|
|
265
|
+
)
|
|
266
|
+
self._jobs[job.job_id] = job
|
|
267
|
+
self._idempotency[request_key] = job.job_id
|
|
268
|
+
self._queue.append(job.job_id)
|
|
269
|
+
self._persist_job(job)
|
|
270
|
+
self._condition.notify()
|
|
271
|
+
return self._snapshot(job)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get(self, caller_id: str, job_id: str) -> dict[str, Any]:
|
|
275
|
+
"""Return a caller-scoped job snapshot without waiting."""
|
|
276
|
+
|
|
277
|
+
with self._condition:
|
|
278
|
+
job = self._owned_job(caller_id, job_id)
|
|
279
|
+
if job is None:
|
|
280
|
+
return self._failure("unknown_job", "job is unknown or belongs to another caller")
|
|
281
|
+
return self._snapshot(job)
|
|
282
|
+
|
|
283
|
+
def status(self) -> dict[str, Any]:
|
|
284
|
+
"""Return a snapshot of worker pool load, queue, and jobs for monitoring."""
|
|
285
|
+
with self._condition:
|
|
286
|
+
jobs_summary = [
|
|
287
|
+
{
|
|
288
|
+
"job_id": job.job_id,
|
|
289
|
+
"caller_id": job.caller_id,
|
|
290
|
+
"state": job.state,
|
|
291
|
+
"created_at": job.created_at,
|
|
292
|
+
"updated_at": job.updated_at,
|
|
293
|
+
"has_result": job.result is not None,
|
|
294
|
+
}
|
|
295
|
+
for job in self._jobs.values()
|
|
296
|
+
]
|
|
297
|
+
return {
|
|
298
|
+
"active_workers": self._active_workers,
|
|
299
|
+
"max_workers": self._max_workers,
|
|
300
|
+
"queued_jobs": len(self._queue),
|
|
301
|
+
"max_queue": self._max_queue,
|
|
302
|
+
"stopping": self._stopping,
|
|
303
|
+
"total_jobs": len(self._jobs),
|
|
304
|
+
"jobs": jobs_summary,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def wait(self, caller_id: str, job_id: str, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
309
|
+
"""Wait at most ``timeout`` seconds, then return the current snapshot."""
|
|
310
|
+
|
|
311
|
+
if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout < 0:
|
|
312
|
+
return self._failure("invalid_timeout", "timeout must be a finite non-negative number")
|
|
313
|
+
with self._condition:
|
|
314
|
+
job = self._owned_job(caller_id, job_id)
|
|
315
|
+
if job is None:
|
|
316
|
+
return self._failure("unknown_job", "job is unknown or belongs to another caller")
|
|
317
|
+
completed = job.completed
|
|
318
|
+
assert completed is not None
|
|
319
|
+
completed.wait(timeout)
|
|
320
|
+
with self._condition:
|
|
321
|
+
return self._snapshot(job)
|
|
322
|
+
|
|
323
|
+
def cancel(self, caller_id: str, job_id: str) -> dict[str, Any]:
|
|
324
|
+
"""Request cooperative cancellation for a queued or running job."""
|
|
325
|
+
|
|
326
|
+
with self._condition:
|
|
327
|
+
job = self._owned_job(caller_id, job_id)
|
|
328
|
+
if job is None:
|
|
329
|
+
return self._failure("unknown_job", "job is unknown or belongs to another caller")
|
|
330
|
+
assert job.cancel_event is not None
|
|
331
|
+
if job.state == "queued":
|
|
332
|
+
try:
|
|
333
|
+
self._queue.remove(job.job_id)
|
|
334
|
+
except ValueError:
|
|
335
|
+
# A worker may have claimed the job between the state check
|
|
336
|
+
# and removal. The worker owns the running transition.
|
|
337
|
+
if job.state != "queued":
|
|
338
|
+
return self._snapshot(job)
|
|
339
|
+
job.cancel_event.set()
|
|
340
|
+
job.state = "cancelled"
|
|
341
|
+
job.updated_at = _now_iso()
|
|
342
|
+
self._release_execution_lease(job)
|
|
343
|
+
self._complete_locked(job)
|
|
344
|
+
self._persist_job(job)
|
|
345
|
+
self._condition.notify_all()
|
|
346
|
+
return self._snapshot(job)
|
|
347
|
+
if job.state == "working":
|
|
348
|
+
job.cancel_event.set()
|
|
349
|
+
snapshot = self._snapshot(job)
|
|
350
|
+
snapshot["cancellation_requested"] = True
|
|
351
|
+
return snapshot
|
|
352
|
+
return self._snapshot(job)
|
|
353
|
+
|
|
354
|
+
def shutdown(self, *, wait: bool = True, timeout: float = 5.0) -> bool:
|
|
355
|
+
"""Stop workers and cancel pending work with a bounded join."""
|
|
356
|
+
|
|
357
|
+
if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout < 0:
|
|
358
|
+
raise ValueError("timeout must be a finite non-negative number")
|
|
359
|
+
with self._condition:
|
|
360
|
+
self._stopping = True
|
|
361
|
+
while self._queue:
|
|
362
|
+
job_id = self._queue.popleft()
|
|
363
|
+
job = self._jobs.get(job_id)
|
|
364
|
+
if job is None or job.state != "queued":
|
|
365
|
+
continue
|
|
366
|
+
assert job.cancel_event is not None
|
|
367
|
+
job.cancel_event.set()
|
|
368
|
+
job.state = "cancelled"
|
|
369
|
+
self._release_execution_lease(job)
|
|
370
|
+
self._complete_locked(job)
|
|
371
|
+
self._persist_job(job)
|
|
372
|
+
for job in self._jobs.values():
|
|
373
|
+
if job.state == "working" and job.cancel_event is not None:
|
|
374
|
+
job.cancel_event.set()
|
|
375
|
+
self._condition.notify_all()
|
|
376
|
+
|
|
377
|
+
if not wait:
|
|
378
|
+
return not any(worker.is_alive() for worker in self._workers)
|
|
379
|
+
deadline = time.monotonic() + timeout
|
|
380
|
+
for worker in self._workers:
|
|
381
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
382
|
+
worker.join(remaining)
|
|
383
|
+
return not any(worker.is_alive() for worker in self._workers)
|
|
384
|
+
|
|
385
|
+
def _worker_loop(self) -> None:
|
|
386
|
+
while True:
|
|
387
|
+
with self._condition:
|
|
388
|
+
while not self._queue and not self._stopping:
|
|
389
|
+
self._condition.wait()
|
|
390
|
+
if not self._queue and self._stopping:
|
|
391
|
+
return
|
|
392
|
+
job_id = self._queue.popleft()
|
|
393
|
+
job = self._jobs.get(job_id)
|
|
394
|
+
if job is None or job.state == "cancelled":
|
|
395
|
+
if job is not None:
|
|
396
|
+
self._release_execution_lease(job)
|
|
397
|
+
continue
|
|
398
|
+
job.state = "working"
|
|
399
|
+
job.updated_at = _now_iso()
|
|
400
|
+
self._active_workers += 1
|
|
401
|
+
self._persist_job(job)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
assert job.cancel_event is not None
|
|
405
|
+
assert job.execution_complete is not None
|
|
406
|
+
if self._execution_gate is not None and job.execution_lease is not None:
|
|
407
|
+
def delegate_and_wait():
|
|
408
|
+
try:
|
|
409
|
+
result = self._service.delegate(
|
|
410
|
+
job.caller_id,
|
|
411
|
+
job.request,
|
|
412
|
+
cancel_event=job.cancel_event,
|
|
413
|
+
completion_event=job.execution_complete,
|
|
414
|
+
)
|
|
415
|
+
finally:
|
|
416
|
+
job.execution_complete.set()
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
result = self._execution_gate.run_reserved(job.execution_lease, delegate_and_wait)
|
|
420
|
+
else:
|
|
421
|
+
try:
|
|
422
|
+
result = self._service.delegate(
|
|
423
|
+
job.caller_id,
|
|
424
|
+
job.request,
|
|
425
|
+
cancel_event=job.cancel_event,
|
|
426
|
+
completion_event=job.execution_complete,
|
|
427
|
+
)
|
|
428
|
+
finally:
|
|
429
|
+
job.execution_complete.set()
|
|
430
|
+
if not isinstance(result, Mapping):
|
|
431
|
+
raise TypeError("service returned a non-object result")
|
|
432
|
+
result = dict(result)
|
|
433
|
+
except Exception: # noqa: BLE001 - worker boundary must terminate the job.
|
|
434
|
+
result = self._failure("worker_error", "delegation worker failed")
|
|
435
|
+
finally:
|
|
436
|
+
if job.execution_complete is not None:
|
|
437
|
+
job.execution_complete.set()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
assert job.execution_complete is not None
|
|
441
|
+
# A service may finish its public call before a cancellable model
|
|
442
|
+
# thread has actually terminated. Keep the worker slot occupied
|
|
443
|
+
# until the service reports that physical execution is complete.
|
|
444
|
+
job.execution_complete.wait()
|
|
445
|
+
self._release_execution_lease(job)
|
|
446
|
+
|
|
447
|
+
with self._condition:
|
|
448
|
+
self._active_workers -= 1
|
|
449
|
+
if job.cancel_event is not None and job.cancel_event.is_set():
|
|
450
|
+
job.state = "cancelled"
|
|
451
|
+
job.result = None
|
|
452
|
+
else:
|
|
453
|
+
job.result = copy.deepcopy(result)
|
|
454
|
+
job.state = "failed" if result.get("status") == "failed" else "completed"
|
|
455
|
+
job.updated_at = _now_iso()
|
|
456
|
+
self._complete_locked(job)
|
|
457
|
+
self._persist_job(job)
|
|
458
|
+
self._condition.notify_all()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _complete_locked(self, job: _Job) -> None:
|
|
462
|
+
assert job.completed is not None
|
|
463
|
+
job.completed.set()
|
|
464
|
+
self._evict_completed_locked()
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _release_execution_lease(job: _Job) -> None:
|
|
468
|
+
if job.execution_lease is not None:
|
|
469
|
+
job.execution_lease.release()
|
|
470
|
+
job.execution_lease = None
|
|
471
|
+
|
|
472
|
+
def _evict_completed_locked(self) -> None:
|
|
473
|
+
terminal_jobs = sum(job.state in _TERMINAL_STATES for job in self._jobs.values())
|
|
474
|
+
while terminal_jobs > self._max_completed_jobs:
|
|
475
|
+
candidate_id = next(
|
|
476
|
+
(job_id for job_id, job in self._jobs.items() if job.state in _TERMINAL_STATES),
|
|
477
|
+
None,
|
|
478
|
+
)
|
|
479
|
+
if candidate_id is None:
|
|
480
|
+
return
|
|
481
|
+
candidate = self._jobs.pop(candidate_id)
|
|
482
|
+
if self._idempotency.get(candidate.request_key) == candidate_id:
|
|
483
|
+
self._idempotency.pop(candidate.request_key, None)
|
|
484
|
+
terminal_jobs -= 1
|
|
485
|
+
|
|
486
|
+
def _owned_job(self, caller_id: str, job_id: str) -> _Job | None:
|
|
487
|
+
job = self._jobs.get(job_id)
|
|
488
|
+
if job is None or job.caller_id != caller_id:
|
|
489
|
+
return None
|
|
490
|
+
return job
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def _snapshot(job: _Job) -> dict[str, Any]:
|
|
494
|
+
snapshot: dict[str, Any] = {
|
|
495
|
+
"job_id": job.job_id,
|
|
496
|
+
"status": job.state,
|
|
497
|
+
"created_at": job.created_at,
|
|
498
|
+
"updated_at": job.updated_at,
|
|
499
|
+
}
|
|
500
|
+
if job.state in {"completed", "failed", "interrupted"} and job.result is not None:
|
|
501
|
+
snapshot["result"] = copy.deepcopy(job.result)
|
|
502
|
+
if job.state == "working" and job.cancel_event is not None and job.cancel_event.is_set():
|
|
503
|
+
snapshot["cancellation_requested"] = True
|
|
504
|
+
return snapshot
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _fingerprint(request: DelegationRequest) -> str:
|
|
508
|
+
return json.dumps(asdict(request), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def _failure(kind: str, message: str) -> dict[str, Any]:
|
|
512
|
+
return {
|
|
513
|
+
"status": "failed",
|
|
514
|
+
"error": {"kind": kind, "message": message},
|
|
515
|
+
"applied": False,
|
|
516
|
+
}
|