agmx 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.
agmx/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``agmx`` -- chạy việc của AgentMatrix ngay trên máy bạn.
2
+
3
+ Gói này cài trên máy NGƯỜI DÙNG: chỉ stdlib + httpx, không Postgres, không
4
+ Redis, không config của server, và tuyệt đối không ``DATABASE_URL``.
5
+ """
6
+
7
+ __version__ = "0.1.0"
agmx/__main__.py ADDED
@@ -0,0 +1,354 @@
1
+ """``agmx`` -- công cụ dòng lệnh chạy trên MÁY CỦA NGƯỜI DÙNG.
2
+
3
+ Hai việc duy nhất:
4
+
5
+ * ``agmx login`` -- đổi email/mật khẩu lấy token, cất vào ``~/.agmx``.
6
+ * ``agmx runner start`` -- nhận việc từ server rồi thực thi ngay tại đây, bằng
7
+ phiên đăng nhập CLI của chính người dùng này.
8
+
9
+ Điểm mấu chốt của thiết kế: **server không nói đường dẫn**. Nó chỉ nói
10
+ ``project_id``; máy này tự tra ra thư mục của mình từ danh sách ``--project``
11
+ đã đăng ký. Một ``project_id`` lạ bị báo ``failed`` chứ không bao giờ được đoán
12
+ thành một đường dẫn nào đó.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import getpass
19
+ import os
20
+ import socket
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import httpx
26
+
27
+ from . import config, executor
28
+ from .client import DEFAULT_SERVER, Client
29
+
30
+ DEFAULT_POLL_SECONDS = 5
31
+
32
+ #: Mã lỗi server trả về -> câu người đọc hiểu được.
33
+ _LOGIN_ERRORS = {
34
+ "invalid_credentials": "email hoặc mật khẩu không đúng.",
35
+ "locked": "tài khoản đang bị khoá (sai mật khẩu quá nhiều lần).",
36
+ "disabled": "tài khoản đã bị vô hiệu hoá.",
37
+ "invalid_request": "server từ chối yêu cầu (thiếu email hoặc mật khẩu).",
38
+ }
39
+
40
+
41
+ def _sleep(seconds: float) -> None:
42
+ """Chỗ duy nhất runner ngủ -- test thay nó để vòng lặp chạy tức thì."""
43
+ time.sleep(seconds)
44
+
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # agmx login
48
+ # --------------------------------------------------------------------------- #
49
+
50
+ def cmd_login(args: argparse.Namespace) -> int:
51
+ cfg = config.load()
52
+ server = args.server or cfg.get("server") or os.environ.get("AGMX_SERVER") or DEFAULT_SERVER
53
+
54
+ email = (args.email or input("Email: ")).strip()
55
+ if not email:
56
+ print("Chưa nhập email, dừng lại.", file=sys.stderr)
57
+ return 1
58
+ # Mật khẩu CHỈ đọc từ TTY: một cờ --password nằm lại trong lịch sử shell và
59
+ # trong argv của tiến trình, ai chạy `ps` cũng đọc được.
60
+ password = getpass.getpass("Mật khẩu: ")
61
+ if not password:
62
+ print("Chưa nhập mật khẩu, dừng lại.", file=sys.stderr)
63
+ return 1
64
+
65
+ try:
66
+ status, body = Client(server).login(email, password)
67
+ except httpx.HTTPError as exc:
68
+ print(f"Không gọi được server {server}: {exc}", file=sys.stderr)
69
+ return 1
70
+
71
+ if status != 200 or not body.get("ok") or not body.get("token"):
72
+ code = str(body.get("error") or f"HTTP {status}")
73
+ print(f"Đăng nhập thất bại: {_LOGIN_ERRORS.get(code, code)}", file=sys.stderr)
74
+ return 1
75
+
76
+ # Ghi cấu hình MỚI, không vá lên cái cũ: đăng nhập là một phiên khác, và
77
+ # runner_id của phiên trước có thể thuộc về người khác.
78
+ config.save({
79
+ "server": server,
80
+ "token": body["token"],
81
+ "expires_at": body.get("expires_at"),
82
+ "email": email,
83
+ })
84
+ name = (body.get("user") or {}).get("name") or email
85
+ print(f"Đã đăng nhập {name} <{email}> tại {server}.")
86
+ print(f"Token cất ở {config.credentials_path()} (quyền 0600).")
87
+ return 0
88
+
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # agmx runner start
92
+ # --------------------------------------------------------------------------- #
93
+
94
+ def parse_projects(pairs: list[str]) -> dict[str, str]:
95
+ """``["agmx=/repo"]`` -> ``{"agmx": "/repo"}``. Đây là bản đồ duy nhất từ
96
+ ``project_id`` ra đường dẫn thật, và nó nằm ở phía máy người dùng."""
97
+ projects: dict[str, str] = {}
98
+ for raw in pairs:
99
+ project_id, separator, local_path = raw.partition("=")
100
+ project_id, local_path = project_id.strip(), local_path.strip()
101
+ if not separator or not project_id or not local_path:
102
+ raise ValueError(
103
+ f"--project phải có dạng PROJECT_ID=/đường/dẫn, nhận được: {raw!r}"
104
+ )
105
+ projects[project_id] = str(Path(local_path).expanduser().resolve())
106
+ return projects
107
+
108
+
109
+ def _enroll(client: Client, projects: dict[str, str]) -> str | None:
110
+ """Đăng ký máy này với server. Trả ``runner_id``, hoặc ``None`` nếu hỏng."""
111
+ try:
112
+ status, body = client.enroll(socket.gethostname(), projects)
113
+ except httpx.HTTPError as exc:
114
+ print(f"Không đăng ký được runner với {client.server}: {exc}", file=sys.stderr)
115
+ return None
116
+ if status != 200 or not body.get("ok") or not body.get("runner_id"):
117
+ print(
118
+ f"Server từ chối đăng ký runner (HTTP {status}): {body.get('error') or body}",
119
+ file=sys.stderr,
120
+ )
121
+ return None
122
+
123
+ runner_id = str(body["runner_id"])
124
+ config.save({**config.load(), "runner_id": runner_id})
125
+ return runner_id
126
+
127
+
128
+ def _report(client: Client, runner_id: str, run_id: str, status: str, **facts) -> None:
129
+ try:
130
+ code, body = client.result(runner_id, run_id, status, **facts)
131
+ except httpx.HTTPError as exc:
132
+ # Mất kết quả của một run thì tiếc, nhưng giết cả runner thì tệ hơn.
133
+ print(f"Không báo được kết quả run {run_id} về server: {exc}", file=sys.stderr)
134
+ return
135
+ if code != 200 or not body.get("ok"):
136
+ # Im lặng ở đây nghĩa là server vẫn nghĩ run đang chạy, còn máy này thì
137
+ # đã quên nó -- nói ra để người dùng còn biết mà tra.
138
+ print(
139
+ f"Server không nhận kết quả run {run_id} (HTTP {code}): "
140
+ f"{body.get('error') or body}",
141
+ file=sys.stderr,
142
+ )
143
+
144
+
145
+ def _output_sink(client: Client, runner_id: str, run_id: str):
146
+ def send(seq: int, stream: str, content: str) -> None:
147
+ try:
148
+ client.output(runner_id, run_id, seq, stream, content)
149
+ except httpx.HTTPError as exc:
150
+ # Mất một mẻ log không phải lý do để bỏ dở việc đang chạy.
151
+ print(f"Không đẩy được output run {run_id} (mẻ {seq}): {exc}", file=sys.stderr)
152
+
153
+ return send
154
+
155
+
156
+ def _handle_run(client: Client, runner_id: str, projects: dict[str, str], run: dict) -> None:
157
+ run_id = str(run.get("run_id") or "")
158
+ task_id = str(run.get("task_id") or "")
159
+ project_id = run.get("project_id")
160
+
161
+ local_path = projects.get(project_id)
162
+ if local_path is None:
163
+ known = ", ".join(sorted(projects)) or "(chưa đăng ký project nào)"
164
+ reason = (
165
+ f"project_id {project_id!r} chưa đăng ký trên máy này; "
166
+ f"runner chỉ nhận việc cho: {known}"
167
+ )
168
+ print(f"Bỏ qua run {run_id}: {reason}", file=sys.stderr)
169
+ _report(client, runner_id, run_id, "failed", error=reason)
170
+ return
171
+
172
+ print(f"Nhận run {run_id} (task {task_id}), project {project_id} -> {local_path}")
173
+ try:
174
+ outcome = executor.execute(
175
+ local_path=local_path,
176
+ run_id=run_id,
177
+ command=str(run.get("command") or ""),
178
+ timeout_seconds=float(run.get("timeout_seconds") or 900),
179
+ on_output=_output_sink(client, runner_id, run_id),
180
+ )
181
+ except Exception as exc: # kể cả lỗi lạ: im lặng nuốt là mất hẳn một run
182
+ reason = f"thực thi thất bại: {type(exc).__name__}: {exc}"
183
+ print(f"Run {run_id} lỗi: {reason}", file=sys.stderr)
184
+ _report(client, runner_id, run_id, "failed", error=reason)
185
+ return
186
+
187
+ # ``status`` là bắt buộc trong hợp đồng, nên nó được nói lại một cách MÁY
188
+ # MÓC từ mã thoát: không đọc output, không suy diễn, không dựng result_ref.
189
+ # Sự việc thô đi kèm ngay bên cạnh để server tự phán.
190
+ if outcome["timed_out"]:
191
+ status = "timeout"
192
+ else:
193
+ status = "success" if outcome["exit_code"] == 0 else "failed"
194
+ print(f"Run {run_id} kết thúc: {status} (mã thoát {outcome['exit_code']}).")
195
+ _report(client, runner_id, run_id, status, **outcome)
196
+
197
+
198
+ def poll_loop(
199
+ client: Client,
200
+ runner_id: str,
201
+ projects: dict[str, str],
202
+ poll_seconds: float = DEFAULT_POLL_SECONDS,
203
+ max_iterations: int | None = None,
204
+ ) -> None:
205
+ """Vòng heartbeat -> lease -> thực thi -> result, tới khi Ctrl-C.
206
+
207
+ ``max_iterations`` chỉ để test không chạy vô hạn.
208
+ """
209
+ iterations = 0
210
+ while max_iterations is None or iterations < max_iterations:
211
+ iterations += 1
212
+ try:
213
+ status, body = client.heartbeat(runner_id)
214
+ if status == 404 and body.get("error") == "unknown_runner":
215
+ # Server không còn biết runner này. Đăng ký lại, chứ poll mãi
216
+ # vào một id đã chết thì runner sống mà không nhận được việc.
217
+ print("Server không còn nhớ runner này, đăng ký lại.", file=sys.stderr)
218
+ new_id = _enroll(client, projects)
219
+ if new_id is None:
220
+ _sleep(poll_seconds)
221
+ continue
222
+ runner_id = new_id
223
+
224
+ status, body = client.lease(runner_id)
225
+ if status != 200 or not body.get("ok"):
226
+ print(
227
+ f"Server từ chối cấp việc (HTTP {status}): {body.get('error') or body}",
228
+ file=sys.stderr,
229
+ )
230
+ _sleep(poll_seconds)
231
+ continue
232
+
233
+ run = body.get("run")
234
+ if not run:
235
+ _sleep(poll_seconds)
236
+ continue
237
+ except httpx.HTTPError as exc:
238
+ # Máy người dùng rớt mạng là chuyện thường ngày, không phải lý do
239
+ # để runner chết.
240
+ print(f"Lỗi mạng: {exc}. Thử lại sau {poll_seconds}s.", file=sys.stderr)
241
+ _sleep(poll_seconds)
242
+ continue
243
+
244
+ _handle_run(client, runner_id, projects, run)
245
+ # Vừa có việc thì nhiều khả năng còn việc nữa: lease tiếp, đừng ngủ.
246
+
247
+
248
+ def cmd_runner_start(args: argparse.Namespace) -> int:
249
+ cfg = config.load()
250
+ token = cfg.get("token")
251
+ if not token:
252
+ print("Chưa đăng nhập, chạy `agmx login` trước.", file=sys.stderr)
253
+ return 1
254
+ server = args.server or cfg.get("server") or DEFAULT_SERVER
255
+
256
+ try:
257
+ projects = parse_projects(args.project)
258
+ except ValueError as exc:
259
+ print(str(exc), file=sys.stderr)
260
+ return 1
261
+ for project_id, local_path in sorted(projects.items()):
262
+ if not Path(local_path).is_dir():
263
+ print(
264
+ f"Cảnh báo: {project_id} trỏ tới {local_path} nhưng đó không "
265
+ f"phải thư mục đang tồn tại.",
266
+ file=sys.stderr,
267
+ )
268
+
269
+ client = Client(server, token)
270
+ runner_id = _enroll(client, projects)
271
+ if runner_id is None:
272
+ return 1
273
+
274
+ print(
275
+ f"Runner {runner_id} chạy trên {socket.gethostname()}, "
276
+ f"hỏi việc {server} mỗi {args.poll_seconds}s. Ctrl-C để dừng."
277
+ )
278
+ poll_loop(client, runner_id, projects, args.poll_seconds)
279
+ return 0
280
+
281
+
282
+ # --------------------------------------------------------------------------- #
283
+ # Vào cửa
284
+ # --------------------------------------------------------------------------- #
285
+
286
+ def build_parser() -> argparse.ArgumentParser:
287
+ parser = argparse.ArgumentParser(
288
+ prog="agmx",
289
+ description=(
290
+ "agmx -- chạy việc của AgentMatrix ngay trên máy bạn, bằng phiên "
291
+ "đăng nhập CLI của chính bạn."
292
+ ),
293
+ )
294
+ subparsers = parser.add_subparsers(dest="command", metavar="LỆNH")
295
+
296
+ login = subparsers.add_parser(
297
+ "login",
298
+ help="đăng nhập vào server và cất token vào ~/.agmx/.credentials.json",
299
+ description="Đăng nhập. Mật khẩu chỉ nhập từ bàn phím, không có cờ --password.",
300
+ )
301
+ login.add_argument("--server", help=f"địa chỉ server (mặc định {DEFAULT_SERVER})")
302
+ login.add_argument("--email", help="email đăng nhập (bỏ trống thì sẽ hỏi)")
303
+ login.set_defaults(func=cmd_login)
304
+
305
+ runner = subparsers.add_parser(
306
+ "runner",
307
+ help="điều khiển runner trên máy này",
308
+ description="Runner: nhận việc từ server rồi chạy ngay tại máy này.",
309
+ )
310
+ runner_sub = runner.add_subparsers(dest="runner_command", metavar="LỆNH")
311
+ start = runner_sub.add_parser(
312
+ "start",
313
+ help="đăng ký máy này rồi nhận việc liên tục",
314
+ description=(
315
+ "Đăng ký máy này với server rồi lặp: xin việc, chạy, báo kết quả. "
316
+ "Server chỉ gửi project_id; đường dẫn thật do máy này quyết định."
317
+ ),
318
+ )
319
+ start.add_argument(
320
+ "--project",
321
+ action="append",
322
+ default=[],
323
+ required=True,
324
+ metavar="PROJECT_ID=/đường/dẫn",
325
+ help="project máy này nhận việc, lặp lại được nhiều lần",
326
+ )
327
+ start.add_argument(
328
+ "--poll-seconds",
329
+ type=float,
330
+ default=DEFAULT_POLL_SECONDS,
331
+ help=f"nghỉ bao lâu giữa hai lần hỏi việc (mặc định {DEFAULT_POLL_SECONDS})",
332
+ )
333
+ start.add_argument("--server", help="ghi đè địa chỉ server đã lưu khi đăng nhập")
334
+ start.set_defaults(func=cmd_runner_start)
335
+ runner.set_defaults(func=lambda _args: (runner.print_help(), 1)[1])
336
+
337
+ return parser
338
+
339
+
340
+ def main(argv: list[str] | None = None) -> int:
341
+ parser = build_parser()
342
+ args = parser.parse_args(argv)
343
+ if not getattr(args, "func", None):
344
+ parser.print_help()
345
+ return 1
346
+ try:
347
+ return args.func(args)
348
+ except KeyboardInterrupt:
349
+ print("\nĐã dừng. Tạm biệt.")
350
+ return 0
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main())
agmx/client.py ADDED
@@ -0,0 +1,65 @@
1
+ """Các route HTTP máy này nói với control plane. Chỉ httpx, không gì khác."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ # Control plane công khai. HTTPS không phải trang trí: ``agmx login`` đặt mật
8
+ # khẩu vào thân yêu cầu, http trần trên internet là đưa nó cho cả đường dây.
9
+ DEFAULT_SERVER = "https://agmx.nothanagentic.vn"
10
+ HTTP_TIMEOUT = 30.0
11
+
12
+
13
+ class Client:
14
+ """Một phiên nói chuyện với server. ``token`` rỗng thì chỉ login được."""
15
+
16
+ def __init__(self, server: str | None, token: str | None = None, timeout: float = HTTP_TIMEOUT):
17
+ self.server = (server or DEFAULT_SERVER).rstrip("/")
18
+ self.token = token
19
+ self._http = httpx.Client(timeout=timeout)
20
+
21
+ def post(self, path: str, payload: dict) -> tuple[int, dict]:
22
+ """POST JSON, trả ``(status_code, body)``. Ném ``httpx.HTTPError`` khi mạng hỏng."""
23
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
24
+ response = self._http.post(self.server + path, json=payload, headers=headers)
25
+ try:
26
+ body = response.json()
27
+ except ValueError:
28
+ body = {}
29
+ return response.status_code, body if isinstance(body, dict) else {}
30
+
31
+ # -- 5 route -------------------------------------------------------- #
32
+
33
+ def login(self, email: str, password: str) -> tuple[int, dict]:
34
+ return self.post("/auth/login", {"email": email, "password": password})
35
+
36
+ def enroll(self, hostname: str, projects: dict[str, str]) -> tuple[int, dict]:
37
+ return self.post("/runner/enroll", {
38
+ "hostname": hostname,
39
+ "projects": [
40
+ {"project_id": project_id, "local_path": local_path}
41
+ for project_id, local_path in sorted(projects.items())
42
+ ],
43
+ })
44
+
45
+ def heartbeat(self, runner_id: str) -> tuple[int, dict]:
46
+ return self.post("/runner/heartbeat", {"runner_id": runner_id})
47
+
48
+ def lease(self, runner_id: str) -> tuple[int, dict]:
49
+ return self.post("/runner/lease", {"runner_id": runner_id})
50
+
51
+ def output(self, runner_id: str, run_id: str, seq: int, stream: str, content: str):
52
+ return self.post("/runner/output", {
53
+ "runner_id": runner_id,
54
+ "run_id": run_id,
55
+ "seq": seq,
56
+ "stream": stream,
57
+ "content": content,
58
+ })
59
+
60
+ def result(self, runner_id: str, run_id: str, status: str, **facts) -> tuple[int, dict]:
61
+ """Báo run đã kết thúc. ``facts``: exit_code, base_sha, head_sha,
62
+ timed_out, error -- những gì máy này QUAN SÁT được, không phải kết luận."""
63
+ payload = {"runner_id": runner_id, "run_id": run_id, "status": status}
64
+ payload.update(facts)
65
+ return self.post("/runner/result", payload)
agmx/config.py ADDED
@@ -0,0 +1,82 @@
1
+ """Cấu hình của ``agmx``: bí mật một nơi, phần còn lại một nơi khác.
2
+
3
+ ``~/.agmx/.credentials.json`` (0600) giữ token; ``~/.agmx/settings.json``
4
+ (0644) giữ những thứ không phải bí mật (server, runner_id, email). Tách ra
5
+ theo đúng cách ``~/.claude`` làm: settings là thứ người ta mở ra xem, sửa, dán
6
+ vào issue; token thì không. Gộp chung một file nghĩa là mỗi lần khoe cấu hình
7
+ là một lần lộ phiên đăng nhập.
8
+
9
+ ``config.json`` kiểu cũ vẫn đọc được, để máy đã cài không phải đăng nhập lại.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+
18
+ #: Những khoá là BÍ MẬT. Chỉ chúng mới được vào file 0600, và mọi khoá khác
19
+ #: đều ra file 0644 -- danh sách này là ranh giới, không phải gợi ý.
20
+ SECRET_KEYS = ("token", "expires_at")
21
+
22
+
23
+ def config_dir() -> Path:
24
+ """Thư mục config. ``AGMX_CONFIG_DIR`` đổi được (test không đụng ``~`` thật)."""
25
+ return Path(os.environ.get("AGMX_CONFIG_DIR") or (Path.home() / ".agmx"))
26
+
27
+
28
+ def credentials_path() -> Path:
29
+ return config_dir() / ".credentials.json"
30
+
31
+
32
+ def settings_path() -> Path:
33
+ return config_dir() / "settings.json"
34
+
35
+
36
+ def legacy_path() -> Path:
37
+ """File một-cục của bản cũ. Chỉ còn được ĐỌC, không bao giờ được ghi nữa."""
38
+ return config_dir() / "config.json"
39
+
40
+
41
+ def _read(path: Path) -> dict:
42
+ try:
43
+ data = json.loads(path.read_text(encoding="utf-8"))
44
+ except (OSError, ValueError):
45
+ return {}
46
+ return data if isinstance(data, dict) else {}
47
+
48
+
49
+ def load() -> dict:
50
+ """Cấu hình đã lưu: file cũ làm nền, file mới đè lên."""
51
+ merged = _read(legacy_path())
52
+ merged.update(_read(settings_path()))
53
+ merged.update(_read(credentials_path()))
54
+ return merged
55
+
56
+
57
+ def _write(path: Path, data: dict, mode: int) -> None:
58
+ """Ghi với quyền đặt TRƯỚC khi có nội dung.
59
+
60
+ ``open()`` rồi ``chmod()`` để lại một khoảng thời gian token nằm trên đĩa
61
+ với quyền mặc định; ai đọc được trong khoảng đó thì đọc được phiên đăng
62
+ nhập. ``os.open`` mang sẵn mode, và ``fchmod`` ép đúng quyền kể cả khi file
63
+ đã tồn tại sẵn với quyền rộng hơn (``O_CREAT`` không sửa quyền file cũ) hay
64
+ khi umask của máy cắt bớt.
65
+ """
66
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
67
+ os.fchmod(fd, mode)
68
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
69
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
70
+
71
+
72
+ def save(data: dict) -> None:
73
+ """Cất cấu hình, tự tách bí mật ra khỏi phần còn lại."""
74
+ directory = config_dir()
75
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
76
+ os.chmod(directory, 0o700) # mkdir(mode=) im lặng bỏ qua khi thư mục đã có
77
+
78
+ _write(credentials_path(), {k: data[k] for k in SECRET_KEYS if k in data}, 0o600)
79
+ _write(settings_path(), {k: v for k, v in data.items() if k not in SECRET_KEYS}, 0o644)
80
+ # Bỏ file cũ đi: token của nó không còn ai đọc nữa nhưng vẫn ai cũng đọc
81
+ # được. Một bản sao bí mật nằm lì trên đĩa là bí mật bị lộ, chỉ chậm hơn.
82
+ legacy_path().unlink(missing_ok=True)
agmx/executor.py ADDED
@@ -0,0 +1,272 @@
1
+ """Chạy MỘT run trên máy này: worktree riêng, spawn, đẩy output theo mẻ.
2
+
3
+ Chỉ ``subprocess`` + ``git``. Không có gì của control plane ở đây.
4
+
5
+ Và cố ý không có: **runner không phán xét**. Nó không quyết định run thành công
6
+ hay thất bại, không dựng ``result_ref``, không đọc output để đoán ý người ta
7
+ muốn gì. Nó trả về sự việc -- mã thoát, base, head, có quá giờ hay không -- rồi
8
+ server phán. Logic phán xét bên server đã trả giá bằng nhiều sự cố thật; chép
9
+ lại ở phía client là mua lại đúng bộ bug đó, lần này trên máy người lạ, nơi
10
+ không ai vá được.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import queue
17
+ import shutil
18
+ import signal
19
+ import subprocess
20
+ import tempfile
21
+ import threading
22
+ import time
23
+ from typing import Callable
24
+
25
+ #: Biến git trỏ vào repo KHÁC. Không xoá khỏi môi trường con thì lệnh git của
26
+ #: agent ghi thẳng vào repo chính dù ``cwd`` là worktree: GIT_DIR thắng cwd,
27
+ #: luôn luôn. Đây là sự cố có thật của dự án (CTV2-1427).
28
+ GIT_POINTER_VARS = (
29
+ "GIT_DIR",
30
+ "GIT_WORK_TREE",
31
+ "GIT_COMMON_DIR",
32
+ "GIT_INDEX_FILE",
33
+ "GIT_OBJECT_DIRECTORY",
34
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
35
+ "GIT_NAMESPACE",
36
+ "GIT_CEILING_DIRECTORIES",
37
+ )
38
+
39
+ #: Bao lâu thì đẩy một mẻ output đi, kể cả khi mẻ còn ngắn. Người ngồi xem một
40
+ #: run 15 phút cần thấy nó đang nói gì, không phải đợi tới lúc nó im hẳn.
41
+ FLUSH_SECONDS = 0.5
42
+ #: Trần của MỘT mẻ, đếm bằng KÝ TỰ chứ không phải byte: server từ chối body quá
43
+ #: 64KiB, và một ký tự UTF-8 dài nhất 4 byte, nên 16k ký tự luôn lọt.
44
+ MAX_BATCH_CHARS = 16_000
45
+ #: Chờ tiến trình chết tử tế bao lâu trước khi SIGKILL cả nhóm.
46
+ KILL_GRACE_SECONDS = 3.0
47
+
48
+ #: Nhận ``(seq, stream, content)``. ``seq`` đánh từ 0, tăng dần theo từng run.
49
+ OutputSink = Callable[[int, str, str], None]
50
+
51
+
52
+ def child_env() -> dict[str, str]:
53
+ """Môi trường cho tiến trình con: bản sao ĐẦY ĐỦ, đã bỏ con trỏ git.
54
+
55
+ Trả về cả môi trường chứ không phải lớp phủ. Xoá một khoá khỏi lớp phủ là
56
+ vô nghĩa -- ``Popen`` dựng môi trường con từ ``os.environ`` rồi mới đắp lớp
57
+ phủ lên, nên khoá đã xoá vẫn thừa kế nguyên vẹn (CTV2-1758).
58
+ """
59
+ env = dict(os.environ)
60
+ for name in GIT_POINTER_VARS:
61
+ env.pop(name, None)
62
+ return env
63
+
64
+
65
+ def _git(cwd: str, *args: str, check: bool = True) -> subprocess.CompletedProcess:
66
+ result = subprocess.run(
67
+ ["git", *args],
68
+ cwd=cwd,
69
+ capture_output=True,
70
+ text=True,
71
+ errors="replace",
72
+ env=child_env(),
73
+ check=False,
74
+ )
75
+ if check and result.returncode != 0:
76
+ detail = (result.stderr or result.stdout or "").strip()
77
+ raise RuntimeError(f"`git {' '.join(args)}` hỏng: {detail}")
78
+ return result
79
+
80
+
81
+ def branch_name(run_id: str) -> str:
82
+ return f"ct-run/{run_id}"
83
+
84
+
85
+ def execute(
86
+ local_path: str,
87
+ run_id: str,
88
+ command: str,
89
+ timeout_seconds: float,
90
+ on_output: OutputSink | None = None,
91
+ ) -> dict:
92
+ """Chạy ``command`` trong worktree riêng của ``run_id``.
93
+
94
+ Trả về ``{exit_code, base_sha, head_sha, timed_out}`` -- dữ liệu thô, không
95
+ kèm lời phán nào. ``head_sha`` là ``None`` khi run không đổi gì cả.
96
+ """
97
+ base_sha = _git(local_path, "rev-parse", "HEAD").stdout.strip()
98
+ parent = tempfile.mkdtemp(prefix=f"agmx-{run_id}-")
99
+ worktree = os.path.join(parent, "worktree")
100
+ # ``-B`` chứ không ``-b``: cùng một run được giao lại (server restart, lease
101
+ # hết hạn) thì làm lại từ đầu, chứ không hỏng vĩnh viễn vì nhánh đã có.
102
+ _git(local_path, "worktree", "add", "-B", branch_name(run_id), worktree, base_sha)
103
+ try:
104
+ exit_code, timed_out = _spawn(command, worktree, timeout_seconds, on_output)
105
+ head_sha = _commit(worktree, run_id)
106
+ finally:
107
+ # Gỡ worktree nhưng GIỮ nhánh: worktree chỉ là thư mục, commit nằm
108
+ # trong object database dùng chung của repo chính và sống tiếp.
109
+ _git(local_path, "worktree", "remove", "--force", worktree, check=False)
110
+ shutil.rmtree(parent, ignore_errors=True)
111
+ return {
112
+ "exit_code": exit_code,
113
+ "base_sha": base_sha,
114
+ "head_sha": head_sha,
115
+ "timed_out": timed_out,
116
+ }
117
+
118
+
119
+ def _commit(worktree: str, run_id: str) -> str | None:
120
+ """Commit những gì run để lại. ``None`` khi nó không để lại gì."""
121
+ _git(worktree, "add", "-A")
122
+ if _git(worktree, "diff", "--cached", "--quiet", check=False).returncode == 0:
123
+ return None
124
+
125
+ message = f"agmx run {run_id}"
126
+ done = _git(worktree, "commit", "-m", message, check=False)
127
+ if done.returncode != 0:
128
+ # Máy chưa khai user.name/user.email thì git từ chối commit. Danh tính
129
+ # thay thế chỉ dùng ở nước này, để commit của người dùng vẫn mang tên
130
+ # thật của họ khi họ có khai.
131
+ done = _git(
132
+ worktree,
133
+ "-c", "user.name=agmx runner",
134
+ "-c", "user.email=agmx@localhost",
135
+ "commit", "-m", message,
136
+ check=False,
137
+ )
138
+ if done.returncode != 0:
139
+ raise RuntimeError(f"commit hỏng: {(done.stderr or done.stdout).strip()}")
140
+ return _git(worktree, "rev-parse", "HEAD").stdout.strip()
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Spawn + stream
145
+ # --------------------------------------------------------------------------- #
146
+
147
+ class _Batches:
148
+ """Gom dòng thành mẻ theo luồng, đánh ``seq`` tăng dần từ 0."""
149
+
150
+ def __init__(self, sink: OutputSink | None, max_chars: int = MAX_BATCH_CHARS):
151
+ self.sink = sink
152
+ self.max_chars = max_chars
153
+ self.seq = 0
154
+ self.pending: dict[str, list[str]] = {}
155
+
156
+ def add(self, stream: str, line: str) -> None:
157
+ batch = self.pending.setdefault(stream, [])
158
+ batch.append(line)
159
+ if sum(map(len, batch)) >= self.max_chars:
160
+ self.flush(stream)
161
+
162
+ def flush(self, stream: str | None = None) -> None:
163
+ for name in [stream] if stream else list(self.pending):
164
+ content = "".join(self.pending.pop(name, ()))
165
+ # Một dòng đơn lẻ cũng có thể vượt trần (log JSON nén một dòng);
166
+ # cắt ở đây chứ không để server trả 413 rồi mất cả mẻ.
167
+ for start in range(0, len(content), self.max_chars):
168
+ self._send(name, content[start:start + self.max_chars])
169
+
170
+ def _send(self, stream: str, content: str) -> None:
171
+ if not content:
172
+ return
173
+ if self.sink is not None:
174
+ self.sink(self.seq, stream, content)
175
+ self.seq += 1
176
+
177
+
178
+ def _pump(pipe, stream: str, lines: queue.Queue) -> None:
179
+ """Đọc một ống theo DÒNG và đẩy sang hàng đợi, không chờ tiến trình xong."""
180
+ try:
181
+ for line in pipe:
182
+ lines.put((stream, line))
183
+ finally:
184
+ pipe.close()
185
+
186
+
187
+ def _drain(lines: queue.Queue, batches: _Batches, timeout: float) -> None:
188
+ try:
189
+ batches.add(*lines.get(timeout=max(timeout, 0)))
190
+ except queue.Empty:
191
+ return
192
+ while True:
193
+ try:
194
+ batches.add(*lines.get_nowait())
195
+ except queue.Empty:
196
+ return
197
+
198
+
199
+ def _kill_group(proc: subprocess.Popen, group: int) -> None:
200
+ """Giết CẢ NHÓM tiến trình, không chỉ tiến trình cha.
201
+
202
+ CLI hay đẻ con; giết mỗi cha thì con sống tiếp, và nó còn giữ nguyên đầu
203
+ ghi của ống stdout -- tiến trình này sẽ đọc một ống không bao giờ đóng.
204
+ SIGKILL gửi cho cả nhóm KỂ CẢ khi cha đã chết vì SIGTERM: thứ đáng sợ là
205
+ đứa con, và nó không chết theo cha.
206
+ """
207
+ _signal_group(group, signal.SIGTERM)
208
+ try:
209
+ proc.wait(timeout=KILL_GRACE_SECONDS)
210
+ except subprocess.TimeoutExpired:
211
+ pass
212
+ _signal_group(group, signal.SIGKILL)
213
+
214
+
215
+ def _signal_group(group: int, sig: int) -> None:
216
+ try:
217
+ os.killpg(group, sig)
218
+ except (ProcessLookupError, PermissionError):
219
+ pass
220
+
221
+
222
+ def _spawn(
223
+ command: str,
224
+ cwd: str,
225
+ timeout_seconds: float,
226
+ on_output: OutputSink | None,
227
+ ) -> tuple[int | None, bool]:
228
+ proc = subprocess.Popen(
229
+ command,
230
+ shell=True,
231
+ cwd=cwd,
232
+ env=child_env(),
233
+ stdout=subprocess.PIPE,
234
+ stderr=subprocess.PIPE,
235
+ text=True,
236
+ errors="replace",
237
+ bufsize=1,
238
+ # Nhóm tiến trình riêng: điều kiện để giết được cả đàn con ở trên.
239
+ start_new_session=True,
240
+ )
241
+ group = proc.pid # setsid: pgid == pid, và đọc ngay bây giờ thì chắc chắn đúng
242
+ lines: queue.Queue = queue.Queue()
243
+ readers = [
244
+ threading.Thread(target=_pump, args=(pipe, stream, lines), daemon=True)
245
+ for pipe, stream in ((proc.stdout, "stdout"), (proc.stderr, "stderr"))
246
+ ]
247
+ for reader in readers:
248
+ reader.start()
249
+
250
+ batches = _Batches(on_output)
251
+ deadline = time.monotonic() + timeout_seconds
252
+ timed_out = False
253
+ while True:
254
+ remaining = deadline - time.monotonic()
255
+ if remaining <= 0:
256
+ timed_out = True
257
+ _kill_group(proc, group)
258
+ break
259
+ _drain(lines, batches, min(remaining, FLUSH_SECONDS))
260
+ batches.flush()
261
+ if proc.poll() is not None and not any(r.is_alive() for r in readers):
262
+ break
263
+
264
+ for reader in readers:
265
+ reader.join(timeout=KILL_GRACE_SECONDS)
266
+ _drain(lines, batches, 0)
267
+ batches.flush()
268
+ try:
269
+ proc.wait(timeout=KILL_GRACE_SECONDS)
270
+ except subprocess.TimeoutExpired:
271
+ pass
272
+ return proc.returncode, timed_out
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: agmx
3
+ Version: 0.1.0
4
+ Summary: Chạy việc của AgentMatrix ngay trên máy bạn, bằng phiên đăng nhập CLI của chính bạn.
5
+ Author: lupca
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/lupca/AgentMatrix
8
+ Project-URL: Source, https://github.com/lupca/AgentMatrix/tree/main/runner
9
+ Keywords: agmx,agentmatrix,coding-agent,runner
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: httpx>=0.24
18
+
19
+ # agmx
20
+
21
+ Chạy việc của AgentMatrix ngay trên **máy bạn**, bằng phiên đăng nhập CLI của
22
+ chính bạn. Gói này chỉ cần Python và `httpx`: không Postgres, không Redis,
23
+ không `DATABASE_URL` của control plane.
24
+
25
+ ## Cài
26
+
27
+ ```bash
28
+ pipx install ./runner # hoặc: pipx install agmx
29
+ ```
30
+
31
+ ## Dùng
32
+
33
+ ```bash
34
+ agmx login # hỏi email + mật khẩu, cất token
35
+ agmx runner start --project agmx=~/code/AgentMatrix
36
+ ```
37
+
38
+ `--project` lặp lại được nhiều lần. Server **chỉ** gửi `project_id`; đường dẫn
39
+ thật do máy này quyết định, và một `project_id` chưa đăng ký ở đây bị báo
40
+ `failed` chứ không bao giờ bị đoán thành một thư mục nào đó.
41
+
42
+ ## Cấu hình ở đâu
43
+
44
+ | File | Quyền | Chứa gì |
45
+ |---|---|---|
46
+ | `~/.agmx/.credentials.json` | `0600` | token đăng nhập |
47
+ | `~/.agmx/settings.json` | `0644` | `server`, `email`, `runner_id` |
48
+
49
+ `AGMX_CONFIG_DIR` đổi được thư mục. File `config.json` một-cục của bản cũ vẫn
50
+ đọc được, và được dọn đi ở lần ghi kế tiếp.
51
+
52
+ ## Một run chạy như thế nào
53
+
54
+ Mỗi run được cấp một `git worktree` riêng trên nhánh `ct-run/<run_id>`, cắt từ
55
+ HEAD hiện tại. Lệnh chạy trong worktree đó, output đẩy về server theo mẻ trong
56
+ lúc đang chạy. Xong thì `git add -A` + commit, gỡ worktree nhưng **giữ nhánh**:
57
+ commit sống tiếp trong repo chính.
58
+
59
+ Runner **không phán xét**: nó báo mã thoát, `base_sha`, `head_sha`, có quá giờ
60
+ hay không. Ai thành ai bại là việc của server.
@@ -0,0 +1,10 @@
1
+ agmx/__init__.py,sha256=Jv8VreW4TpZg2Q6sig5j7Z51Kq24QbNOSY90UpNwoxU,265
2
+ agmx/__main__.py,sha256=jvdCOHNgtVNYRsKlHH-T-JPKEeNY9WOu6YxR936e7bE,14082
3
+ agmx/client.py,sha256=hTivg69RFxKfX6fGTs-rqMWqKDEJm19s8Z86a6cjcGg,2829
4
+ agmx/config.py,sha256=deR7TjuzpuuBlTblAl8TERGE7hPxzry-mZUv7rZcsFA,3385
5
+ agmx/executor.py,sha256=LcjCWJNerU73aVbJM_RMQKcLILQueFlyzhyHB_pAY-o,10008
6
+ agmx-0.1.0.dist-info/METADATA,sha256=b9mFZ5xtJ0khSpEBzmsfJ4AEKA6Q0mZPEwRaV6hL9So,2340
7
+ agmx-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ agmx-0.1.0.dist-info/entry_points.txt,sha256=vORGlZDzZ7lwDMiF9NcoAjdiVc5dyVstT0WKTLI_JpM,44
9
+ agmx-0.1.0.dist-info/top_level.txt,sha256=yPHqVMtQ7iM-JK4O-zaZNnvUbOaPstG8nYmkFcEfoN4,5
10
+ agmx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agmx = agmx.__main__:main
@@ -0,0 +1 @@
1
+ agmx