kiwimatecoder 0.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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/jobs.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Detached background and scheduled agent jobs.
|
|
2
|
+
|
|
3
|
+
A job is one headless agent run (``python -m kiwimatecoder -p ... --output-format
|
|
4
|
+
json``) tracked by a JSON record under ``~/.kiwimatecoder/jobs/<id>.json``. Jobs
|
|
5
|
+
are detached (their own session) and write combined output to
|
|
6
|
+
``~/.kiwimatecoder/jobs/<id>.log``, so they keep running after the process that
|
|
7
|
+
started them exits.
|
|
8
|
+
|
|
9
|
+
Scheduling is deliberately pull-based: :func:`schedule_job` records an interval
|
|
10
|
+
and :func:`run_due_jobs` starts whatever is due. Nothing runs on its own --
|
|
11
|
+
invoke ``run_due_jobs()`` from the CLI (``kiwimatecoder jobs tick``), a cron
|
|
12
|
+
entry, or a loop. There is no daemon and no platform cron integration.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Sequence
|
|
29
|
+
|
|
30
|
+
from kiwimatecoder.config import (
|
|
31
|
+
ensure_config_dir,
|
|
32
|
+
get_provider_config,
|
|
33
|
+
get_selected_provider_id,
|
|
34
|
+
load_config,
|
|
35
|
+
require_model,
|
|
36
|
+
)
|
|
37
|
+
from kiwimatecoder.permissions import PermissionMode
|
|
38
|
+
|
|
39
|
+
JOB_STATUSES = ("running", "succeeded", "failed", "cancelled")
|
|
40
|
+
TERMINAL_STATUSES = frozenset({"succeeded", "failed", "cancelled"})
|
|
41
|
+
DEFAULT_GRACE_SECONDS = 5.0
|
|
42
|
+
MAX_OUTPUT_BYTES = 100_000
|
|
43
|
+
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
44
|
+
|
|
45
|
+
# Live Popen handles for jobs started by this process; other processes reason
|
|
46
|
+
# about liveness from the recorded pid alone.
|
|
47
|
+
_PROCESSES: dict[str, subprocess.Popen[Any]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class JobError(RuntimeError):
|
|
51
|
+
"""Raised for background-job lifecycle failures."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class JobRecord:
|
|
56
|
+
"""Persisted state for one background agent job."""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
prompt: str
|
|
60
|
+
workspace: str
|
|
61
|
+
provider: str | None = None
|
|
62
|
+
model: str | None = None
|
|
63
|
+
mode: str = "auto-accept"
|
|
64
|
+
status: str = "running"
|
|
65
|
+
pid: int | None = None
|
|
66
|
+
created_at: str = ""
|
|
67
|
+
finished_at: str | None = None
|
|
68
|
+
exit_code: int | None = None
|
|
69
|
+
result: str | None = None
|
|
70
|
+
error: str | None = None
|
|
71
|
+
output_path: str = ""
|
|
72
|
+
interval: float | None = None
|
|
73
|
+
next_run_at: str | None = None
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"id": self.id,
|
|
78
|
+
"prompt": self.prompt,
|
|
79
|
+
"workspace": self.workspace,
|
|
80
|
+
"provider": self.provider,
|
|
81
|
+
"model": self.model,
|
|
82
|
+
"mode": self.mode,
|
|
83
|
+
"status": self.status,
|
|
84
|
+
"pid": self.pid,
|
|
85
|
+
"created_at": self.created_at,
|
|
86
|
+
"finished_at": self.finished_at,
|
|
87
|
+
"exit_code": self.exit_code,
|
|
88
|
+
"result": self.result,
|
|
89
|
+
"error": self.error,
|
|
90
|
+
"output_path": self.output_path,
|
|
91
|
+
"interval": self.interval,
|
|
92
|
+
"next_run_at": self.next_run_at,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, data: dict[str, Any]) -> "JobRecord":
|
|
97
|
+
"""Rebuild a record, tolerating missing or mistyped fields."""
|
|
98
|
+
job_id = str(data.get("id") or "").strip()
|
|
99
|
+
if not job_id:
|
|
100
|
+
raise ValueError("Job record has no id.")
|
|
101
|
+
status = str(data.get("status") or "running").strip().lower()
|
|
102
|
+
if status not in JOB_STATUSES:
|
|
103
|
+
status = "failed" if data.get("finished_at") else "running"
|
|
104
|
+
provider = data.get("provider")
|
|
105
|
+
model = data.get("model")
|
|
106
|
+
interval_value = data.get("interval")
|
|
107
|
+
try:
|
|
108
|
+
interval = float(interval_value) if interval_value is not None else None
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
interval = None
|
|
111
|
+
return cls(
|
|
112
|
+
id=job_id,
|
|
113
|
+
prompt=str(data.get("prompt") or ""),
|
|
114
|
+
workspace=str(data.get("workspace") or "."),
|
|
115
|
+
provider=str(provider) if provider else None,
|
|
116
|
+
model=str(model) if model else None,
|
|
117
|
+
mode=str(data.get("mode") or "auto-accept"),
|
|
118
|
+
status=status,
|
|
119
|
+
pid=_optional_int(data.get("pid")),
|
|
120
|
+
created_at=str(data.get("created_at") or ""),
|
|
121
|
+
finished_at=str(data["finished_at"]) if data.get("finished_at") else None,
|
|
122
|
+
exit_code=_optional_int(data.get("exit_code")),
|
|
123
|
+
result=str(data["result"]) if data.get("result") is not None else None,
|
|
124
|
+
error=str(data["error"]) if data.get("error") is not None else None,
|
|
125
|
+
output_path=str(data.get("output_path") or ""),
|
|
126
|
+
interval=interval,
|
|
127
|
+
next_run_at=str(data["next_run_at"]) if data.get("next_run_at") else None,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _optional_int(value: object) -> int | None:
|
|
132
|
+
if value is None:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return int(value) # type: ignore[call-overload]
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _now_iso() -> str:
|
|
141
|
+
return datetime.datetime.now().isoformat(timespec="seconds")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_iso(value: str) -> float:
|
|
145
|
+
try:
|
|
146
|
+
return datetime.datetime.fromisoformat(value).timestamp()
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
return float("inf")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _jobs_dir() -> Path:
|
|
152
|
+
path = ensure_config_dir() / "jobs"
|
|
153
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
try:
|
|
155
|
+
os.chmod(path, 0o700)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
return path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _safe_id(job_id: str) -> str | None:
|
|
162
|
+
cleaned = str(job_id).strip()
|
|
163
|
+
return cleaned if _JOB_ID_RE.match(cleaned) else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _record_path(job_id: str) -> Path:
|
|
167
|
+
return _jobs_dir() / f"{job_id}.json"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _output_path(job_id: str) -> Path:
|
|
171
|
+
return _jobs_dir() / f"{job_id}.log"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
175
|
+
tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex[:8]}.tmp")
|
|
176
|
+
tmp.write_text(text, encoding="utf-8")
|
|
177
|
+
os.replace(tmp, path)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def save_job(record: JobRecord) -> Path:
|
|
181
|
+
"""Persist a job record with an atomic replace."""
|
|
182
|
+
path = _record_path(record.id)
|
|
183
|
+
_atomic_write(path, json.dumps(record.to_dict(), indent=2) + "\n")
|
|
184
|
+
return path
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_record(path: Path) -> JobRecord | None:
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
190
|
+
except (OSError, json.JSONDecodeError):
|
|
191
|
+
return None
|
|
192
|
+
if not isinstance(data, dict):
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
return JobRecord.from_dict(data)
|
|
196
|
+
except (TypeError, ValueError):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_job(job_id: str) -> JobRecord | None:
|
|
201
|
+
"""Load one job record; corrupt or missing files return None."""
|
|
202
|
+
safe = _safe_id(job_id)
|
|
203
|
+
if safe is None:
|
|
204
|
+
return None
|
|
205
|
+
path = _record_path(safe)
|
|
206
|
+
if not path.is_file():
|
|
207
|
+
return None
|
|
208
|
+
return _load_record(path)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def list_jobs(*, refresh: bool = False) -> list[JobRecord]:
|
|
212
|
+
"""List job records newest first, skipping unreadable files."""
|
|
213
|
+
records: list[JobRecord] = []
|
|
214
|
+
try:
|
|
215
|
+
paths = list(_jobs_dir().glob("*.json"))
|
|
216
|
+
except OSError:
|
|
217
|
+
return []
|
|
218
|
+
for path in paths:
|
|
219
|
+
record = _load_record(path)
|
|
220
|
+
if record is not None:
|
|
221
|
+
records.append(record)
|
|
222
|
+
records.sort(key=lambda record: record.created_at, reverse=True)
|
|
223
|
+
if refresh:
|
|
224
|
+
refreshed: list[JobRecord] = []
|
|
225
|
+
for record in records:
|
|
226
|
+
if record.status == "running":
|
|
227
|
+
record = refresh_job(record.id) or record
|
|
228
|
+
refreshed.append(record)
|
|
229
|
+
refreshed.sort(key=lambda record: record.created_at, reverse=True)
|
|
230
|
+
return refreshed
|
|
231
|
+
return records
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _pid_alive(pid: int | None) -> bool:
|
|
235
|
+
if not pid or pid <= 0:
|
|
236
|
+
return False
|
|
237
|
+
try:
|
|
238
|
+
os.kill(pid, 0)
|
|
239
|
+
except ProcessLookupError:
|
|
240
|
+
return False
|
|
241
|
+
except PermissionError:
|
|
242
|
+
return True
|
|
243
|
+
except OSError:
|
|
244
|
+
return False
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_result(output_path: str) -> dict[str, Any] | None:
|
|
249
|
+
"""Return the final JSON result line from a job's output, if present."""
|
|
250
|
+
if not output_path:
|
|
251
|
+
return None
|
|
252
|
+
try:
|
|
253
|
+
text = Path(output_path).read_text(encoding="utf-8", errors="replace")
|
|
254
|
+
except OSError:
|
|
255
|
+
return None
|
|
256
|
+
for line in reversed(text.splitlines()):
|
|
257
|
+
stripped = line.strip()
|
|
258
|
+
if not stripped.startswith("{"):
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
data = json.loads(stripped)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
continue
|
|
264
|
+
if isinstance(data, dict) and "success" in data:
|
|
265
|
+
return data
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def read_job_output(job_id: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str | None:
|
|
270
|
+
"""Return bounded combined output for a job, or None when unknown."""
|
|
271
|
+
record = get_job(job_id)
|
|
272
|
+
if record is None:
|
|
273
|
+
return None
|
|
274
|
+
if not record.output_path:
|
|
275
|
+
return ""
|
|
276
|
+
try:
|
|
277
|
+
with Path(record.output_path).open("rb") as handle:
|
|
278
|
+
data = handle.read(max_bytes + 1)
|
|
279
|
+
except OSError:
|
|
280
|
+
return ""
|
|
281
|
+
truncated = len(data) > max_bytes
|
|
282
|
+
if truncated:
|
|
283
|
+
data = data[:max_bytes]
|
|
284
|
+
text = data.decode("utf-8", errors="replace")
|
|
285
|
+
if truncated:
|
|
286
|
+
text += "\n... [output truncated]"
|
|
287
|
+
return text
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def start_job(
|
|
291
|
+
prompt: str,
|
|
292
|
+
*,
|
|
293
|
+
workspace: str | Path,
|
|
294
|
+
provider: str | None = None,
|
|
295
|
+
model: str | None = None,
|
|
296
|
+
mode: str | PermissionMode = "auto-accept",
|
|
297
|
+
extra_args: Sequence[str] = (),
|
|
298
|
+
) -> JobRecord:
|
|
299
|
+
"""Spawn a detached headless agent run and return its record.
|
|
300
|
+
|
|
301
|
+
``mode`` defaults to ``auto-accept`` so the job can act unattended; pass
|
|
302
|
+
``mode="ask"`` or ``mode="plan"`` to override (an unattended ``ask`` job
|
|
303
|
+
simply has its approvals denied). Raises ``ValueError`` for an empty prompt,
|
|
304
|
+
a missing workspace, an unknown mode, or when no model was passed or chosen
|
|
305
|
+
for the provider; ``KeyError`` for an unknown provider; and ``JobError``
|
|
306
|
+
when the process cannot be started.
|
|
307
|
+
"""
|
|
308
|
+
cleaned_prompt = str(prompt).strip()
|
|
309
|
+
if not cleaned_prompt:
|
|
310
|
+
raise ValueError("Job prompt is required.")
|
|
311
|
+
workspace_path = Path(workspace).expanduser()
|
|
312
|
+
if not workspace_path.is_dir():
|
|
313
|
+
raise ValueError(f"Workspace is not a directory: {workspace_path}")
|
|
314
|
+
permission = PermissionMode.from_str(mode) if isinstance(mode, str) else mode
|
|
315
|
+
resolved_mode = permission.value
|
|
316
|
+
if provider is not None:
|
|
317
|
+
get_provider_config(provider) # raises UnknownProviderError (a KeyError)
|
|
318
|
+
# Fail now rather than in the detached run: there are no default models.
|
|
319
|
+
# The run starts in the workspace, so its project config decides.
|
|
320
|
+
cfg = load_config(workspace_path)
|
|
321
|
+
require_model(
|
|
322
|
+
get_provider_config(provider or get_selected_provider_id(cfg), cfg),
|
|
323
|
+
cfg,
|
|
324
|
+
override=model,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
job_id = uuid.uuid4().hex[:12]
|
|
328
|
+
output_path = _output_path(job_id)
|
|
329
|
+
argv = [
|
|
330
|
+
sys.executable,
|
|
331
|
+
"-m",
|
|
332
|
+
"kiwimatecoder",
|
|
333
|
+
"-p",
|
|
334
|
+
cleaned_prompt,
|
|
335
|
+
"--output-format",
|
|
336
|
+
"json",
|
|
337
|
+
"--workspace",
|
|
338
|
+
str(workspace_path),
|
|
339
|
+
"--mode",
|
|
340
|
+
resolved_mode,
|
|
341
|
+
]
|
|
342
|
+
if provider:
|
|
343
|
+
argv += ["--provider", provider]
|
|
344
|
+
if model:
|
|
345
|
+
argv += ["--model", model]
|
|
346
|
+
argv += [str(arg) for arg in extra_args]
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
output_file = output_path.open("wb")
|
|
350
|
+
except OSError as exc:
|
|
351
|
+
raise JobError(f"Failed to create the job output file: {exc}") from exc
|
|
352
|
+
try:
|
|
353
|
+
proc = subprocess.Popen(
|
|
354
|
+
argv,
|
|
355
|
+
stdin=subprocess.DEVNULL,
|
|
356
|
+
stdout=output_file,
|
|
357
|
+
stderr=subprocess.STDOUT,
|
|
358
|
+
cwd=str(workspace_path),
|
|
359
|
+
start_new_session=True,
|
|
360
|
+
)
|
|
361
|
+
except OSError as exc:
|
|
362
|
+
raise JobError(f"Failed to start the job process: {exc}") from exc
|
|
363
|
+
finally:
|
|
364
|
+
output_file.close()
|
|
365
|
+
|
|
366
|
+
record = JobRecord(
|
|
367
|
+
id=job_id,
|
|
368
|
+
prompt=cleaned_prompt,
|
|
369
|
+
workspace=str(workspace_path),
|
|
370
|
+
provider=provider,
|
|
371
|
+
model=model,
|
|
372
|
+
mode=resolved_mode,
|
|
373
|
+
status="running",
|
|
374
|
+
pid=proc.pid,
|
|
375
|
+
created_at=_now_iso(),
|
|
376
|
+
output_path=str(output_path),
|
|
377
|
+
)
|
|
378
|
+
_PROCESSES[job_id] = proc
|
|
379
|
+
try:
|
|
380
|
+
save_job(record)
|
|
381
|
+
except OSError as exc:
|
|
382
|
+
_PROCESSES.pop(job_id, None)
|
|
383
|
+
raise JobError(f"Failed to save the job record: {exc}") from exc
|
|
384
|
+
return record
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def refresh_job(job_id: str) -> JobRecord | None:
|
|
388
|
+
"""Update a job's status from its process and parsed JSON result."""
|
|
389
|
+
record = get_job(job_id)
|
|
390
|
+
if record is None:
|
|
391
|
+
return None
|
|
392
|
+
if record.status in TERMINAL_STATUSES:
|
|
393
|
+
return record
|
|
394
|
+
|
|
395
|
+
proc = _PROCESSES.get(job_id)
|
|
396
|
+
code: int | None = None
|
|
397
|
+
if proc is not None:
|
|
398
|
+
code = proc.poll()
|
|
399
|
+
if code is None:
|
|
400
|
+
return record
|
|
401
|
+
elif _pid_alive(record.pid):
|
|
402
|
+
return record
|
|
403
|
+
else:
|
|
404
|
+
code = record.exit_code
|
|
405
|
+
|
|
406
|
+
_PROCESSES.pop(job_id, None)
|
|
407
|
+
record.exit_code = code
|
|
408
|
+
payload = _parse_result(record.output_path)
|
|
409
|
+
if payload is not None:
|
|
410
|
+
record.result = str(payload.get("result") or "")
|
|
411
|
+
success = payload.get("success")
|
|
412
|
+
if success is False or (code not in (0, None)):
|
|
413
|
+
record.status = "failed"
|
|
414
|
+
record.error = record.error or "The job reported failure."
|
|
415
|
+
else:
|
|
416
|
+
record.status = "succeeded"
|
|
417
|
+
elif code == 0:
|
|
418
|
+
record.status = "succeeded"
|
|
419
|
+
record.result = ""
|
|
420
|
+
else:
|
|
421
|
+
record.status = "failed"
|
|
422
|
+
record.error = (
|
|
423
|
+
f"Job exited with code {code} before producing a result."
|
|
424
|
+
if code is not None
|
|
425
|
+
else "Job process exited without producing a result."
|
|
426
|
+
)
|
|
427
|
+
record.finished_at = _now_iso()
|
|
428
|
+
save_job(record)
|
|
429
|
+
return record
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _terminate(proc: subprocess.Popen[Any], *, grace: float = DEFAULT_GRACE_SECONDS) -> int | None:
|
|
433
|
+
"""SIGTERM a process, escalate to SIGKILL, and return its exit code."""
|
|
434
|
+
if proc.poll() is not None:
|
|
435
|
+
return proc.returncode
|
|
436
|
+
proc.terminate()
|
|
437
|
+
try:
|
|
438
|
+
return proc.wait(timeout=grace)
|
|
439
|
+
except subprocess.TimeoutExpired:
|
|
440
|
+
pass
|
|
441
|
+
proc.kill()
|
|
442
|
+
try:
|
|
443
|
+
return proc.wait(timeout=2.0)
|
|
444
|
+
except subprocess.TimeoutExpired:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _terminate_pid(pid: int | None, *, grace: float = DEFAULT_GRACE_SECONDS) -> None:
|
|
449
|
+
if not _pid_alive(pid):
|
|
450
|
+
return
|
|
451
|
+
assert pid is not None
|
|
452
|
+
try:
|
|
453
|
+
os.kill(pid, signal.SIGTERM)
|
|
454
|
+
except OSError:
|
|
455
|
+
return
|
|
456
|
+
deadline = time.monotonic() + grace
|
|
457
|
+
while time.monotonic() < deadline:
|
|
458
|
+
if not _pid_alive(pid):
|
|
459
|
+
return
|
|
460
|
+
time.sleep(0.05)
|
|
461
|
+
try:
|
|
462
|
+
os.kill(pid, signal.SIGKILL)
|
|
463
|
+
except OSError:
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def cancel_job(job_id: str) -> JobRecord | None:
|
|
468
|
+
"""Terminate a running job and mark it cancelled; returns the record."""
|
|
469
|
+
record = get_job(job_id)
|
|
470
|
+
if record is None:
|
|
471
|
+
return None
|
|
472
|
+
if record.status in TERMINAL_STATUSES:
|
|
473
|
+
return record
|
|
474
|
+
proc = _PROCESSES.pop(job_id, None)
|
|
475
|
+
if proc is not None:
|
|
476
|
+
record.exit_code = _terminate(proc)
|
|
477
|
+
else:
|
|
478
|
+
_terminate_pid(record.pid)
|
|
479
|
+
record.status = "cancelled"
|
|
480
|
+
record.finished_at = _now_iso()
|
|
481
|
+
save_job(record)
|
|
482
|
+
return record
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def clear_jobs(*, finished_only: bool = True) -> int:
|
|
486
|
+
"""Delete job records (and output logs); returns how many were removed.
|
|
487
|
+
|
|
488
|
+
With ``finished_only=False`` running jobs are cancelled first so no
|
|
489
|
+
untracked process is left behind.
|
|
490
|
+
"""
|
|
491
|
+
removed = 0
|
|
492
|
+
for record in list_jobs():
|
|
493
|
+
if record.status not in TERMINAL_STATUSES:
|
|
494
|
+
if finished_only:
|
|
495
|
+
continue
|
|
496
|
+
cancel_job(record.id)
|
|
497
|
+
_PROCESSES.pop(record.id, None)
|
|
498
|
+
try:
|
|
499
|
+
_record_path(record.id).unlink(missing_ok=True)
|
|
500
|
+
except OSError:
|
|
501
|
+
continue
|
|
502
|
+
if record.output_path:
|
|
503
|
+
try:
|
|
504
|
+
Path(record.output_path).unlink(missing_ok=True)
|
|
505
|
+
except OSError:
|
|
506
|
+
pass
|
|
507
|
+
removed += 1
|
|
508
|
+
return removed
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def schedule_job(
|
|
512
|
+
prompt: str,
|
|
513
|
+
every_seconds: float | int,
|
|
514
|
+
*,
|
|
515
|
+
workspace: str | Path,
|
|
516
|
+
provider: str | None = None,
|
|
517
|
+
model: str | None = None,
|
|
518
|
+
mode: str | PermissionMode = "auto-accept",
|
|
519
|
+
extra_args: Sequence[str] = (),
|
|
520
|
+
) -> JobRecord:
|
|
521
|
+
"""Start a job now and record how often it should run again.
|
|
522
|
+
|
|
523
|
+
The first run starts immediately; :func:`run_due_jobs` starts subsequent
|
|
524
|
+
runs once ``next_run_at`` has passed. Raises ``ValueError`` for a
|
|
525
|
+
non-positive interval.
|
|
526
|
+
"""
|
|
527
|
+
try:
|
|
528
|
+
interval = float(every_seconds)
|
|
529
|
+
except (TypeError, ValueError) as exc:
|
|
530
|
+
raise ValueError("Schedule interval must be a number of seconds.") from exc
|
|
531
|
+
if interval <= 0:
|
|
532
|
+
raise ValueError("Schedule interval must be positive.")
|
|
533
|
+
record = start_job(
|
|
534
|
+
prompt,
|
|
535
|
+
workspace=workspace,
|
|
536
|
+
provider=provider,
|
|
537
|
+
model=model,
|
|
538
|
+
mode=mode,
|
|
539
|
+
extra_args=extra_args,
|
|
540
|
+
)
|
|
541
|
+
record.interval = interval
|
|
542
|
+
record.next_run_at = datetime.datetime.fromtimestamp(
|
|
543
|
+
time.time() + interval
|
|
544
|
+
).isoformat(timespec="seconds")
|
|
545
|
+
save_job(record)
|
|
546
|
+
return record
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def run_due_jobs(
|
|
550
|
+
*,
|
|
551
|
+
now: float | None = None,
|
|
552
|
+
skipped: list[tuple[JobRecord, str]] | None = None,
|
|
553
|
+
) -> list[JobRecord]:
|
|
554
|
+
"""Start a new run for every scheduled job whose interval has elapsed.
|
|
555
|
+
|
|
556
|
+
A job that is still running is skipped (runs never overlap); a finished job
|
|
557
|
+
hands the schedule to the fresh run. Returns the newly started records.
|
|
558
|
+
Due jobs that could not start (e.g. no model chosen) are appended to
|
|
559
|
+
``skipped`` with the reason, when given.
|
|
560
|
+
"""
|
|
561
|
+
current = time.time() if now is None else now
|
|
562
|
+
started: list[JobRecord] = []
|
|
563
|
+
for scheduled in list_jobs():
|
|
564
|
+
if scheduled.interval is None or scheduled.next_run_at is None:
|
|
565
|
+
continue
|
|
566
|
+
if _parse_iso(scheduled.next_run_at) > current:
|
|
567
|
+
continue
|
|
568
|
+
refreshed = refresh_job(scheduled.id) or scheduled
|
|
569
|
+
if refreshed.status == "running" or refreshed.interval is None:
|
|
570
|
+
continue
|
|
571
|
+
try:
|
|
572
|
+
fresh = start_job(
|
|
573
|
+
refreshed.prompt,
|
|
574
|
+
workspace=refreshed.workspace,
|
|
575
|
+
provider=refreshed.provider,
|
|
576
|
+
model=refreshed.model,
|
|
577
|
+
mode=refreshed.mode,
|
|
578
|
+
)
|
|
579
|
+
except (ValueError, KeyError, JobError) as exc:
|
|
580
|
+
# A broken schedule (missing workspace, retired provider, no model)
|
|
581
|
+
# is left alone so the caller can inspect and fix or cancel it.
|
|
582
|
+
if skipped is not None:
|
|
583
|
+
skipped.append((refreshed, str(exc)))
|
|
584
|
+
continue
|
|
585
|
+
fresh.interval = refreshed.interval
|
|
586
|
+
fresh.next_run_at = datetime.datetime.fromtimestamp(
|
|
587
|
+
current + refreshed.interval
|
|
588
|
+
).isoformat(timespec="seconds")
|
|
589
|
+
save_job(fresh)
|
|
590
|
+
refreshed.interval = None
|
|
591
|
+
refreshed.next_run_at = None
|
|
592
|
+
save_job(refreshed)
|
|
593
|
+
started.append(fresh)
|
|
594
|
+
return started
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Language Server Protocol support: diagnostics and navigation.
|
|
2
|
+
|
|
3
|
+
Opt-in via the ``lsp`` config section. The manager starts a server subprocess
|
|
4
|
+
per language on first use, routes files by extension, and exposes bounded
|
|
5
|
+
diagnostics/definition/references calls; failures are reported, never fatal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from kiwimatecoder.lsp.client import (
|
|
9
|
+
LspClient,
|
|
10
|
+
LspError,
|
|
11
|
+
LspTimeout,
|
|
12
|
+
language_id_for,
|
|
13
|
+
normalize_diagnostics,
|
|
14
|
+
normalize_locations,
|
|
15
|
+
path_to_uri,
|
|
16
|
+
uri_to_path,
|
|
17
|
+
)
|
|
18
|
+
from kiwimatecoder.lsp.manager import (
|
|
19
|
+
LSP_DISABLED_MESSAGE,
|
|
20
|
+
SERVER_PRESETS,
|
|
21
|
+
LspManager,
|
|
22
|
+
LspServerSpec,
|
|
23
|
+
available_servers,
|
|
24
|
+
effective_servers,
|
|
25
|
+
ensure_manager,
|
|
26
|
+
get_manager,
|
|
27
|
+
set_manager,
|
|
28
|
+
)
|
|
29
|
+
from kiwimatecoder.lsp.protocol import (
|
|
30
|
+
LspProtocolError,
|
|
31
|
+
decode_messages,
|
|
32
|
+
encode_message,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"LSP_DISABLED_MESSAGE",
|
|
37
|
+
"SERVER_PRESETS",
|
|
38
|
+
"LspClient",
|
|
39
|
+
"LspError",
|
|
40
|
+
"LspManager",
|
|
41
|
+
"LspProtocolError",
|
|
42
|
+
"LspServerSpec",
|
|
43
|
+
"LspTimeout",
|
|
44
|
+
"available_servers",
|
|
45
|
+
"decode_messages",
|
|
46
|
+
"effective_servers",
|
|
47
|
+
"encode_message",
|
|
48
|
+
"ensure_manager",
|
|
49
|
+
"get_manager",
|
|
50
|
+
"language_id_for",
|
|
51
|
+
"normalize_diagnostics",
|
|
52
|
+
"normalize_locations",
|
|
53
|
+
"path_to_uri",
|
|
54
|
+
"set_manager",
|
|
55
|
+
"uri_to_path",
|
|
56
|
+
]
|