do-again 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.
- do_again/__init__.py +0 -0
- do_again/cli.py +63 -0
- do_again/core/__init__.py +0 -0
- do_again/core/agent.py +402 -0
- do_again/core/executor.py +1298 -0
- do_again/core/schema.py +116 -0
- do_again/platforms/__init__.py +0 -0
- do_again/platforms/base.py +10 -0
- do_again/platforms/detect.py +16 -0
- do_again/platforms/linux.py +7 -0
- do_again/platforms/macos.py +7 -0
- do_again/platforms/windows.py +5 -0
- do_again/service/__init__.py +0 -0
- do_again-0.1.0.dist-info/METADATA +111 -0
- do_again-0.1.0.dist-info/RECORD +18 -0
- do_again-0.1.0.dist-info/WHEEL +4 -0
- do_again-0.1.0.dist-info/entry_points.txt +2 -0
- do_again-0.1.0.dist-info/licenses/LICENSE +9 -0
do_again/__init__.py
ADDED
|
File without changes
|
do_again/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .platforms.detect import detect_platform
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def doctor() -> int:
|
|
13
|
+
info = detect_platform()
|
|
14
|
+
checks = {
|
|
15
|
+
"python": sys.version_info >= (3, 11),
|
|
16
|
+
"git": shutil.which("git") is not None,
|
|
17
|
+
"platform": info.supported,
|
|
18
|
+
}
|
|
19
|
+
for key, ok in checks.items():
|
|
20
|
+
print(f"{'OK' if ok else 'FAIL'} {key}")
|
|
21
|
+
print(f"platform_name={info.name}")
|
|
22
|
+
print(f"service_manager={info.service_manager}")
|
|
23
|
+
return 0 if all(checks.values()) else 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def status() -> int:
|
|
27
|
+
info = detect_platform()
|
|
28
|
+
print(f"platform={info.name}")
|
|
29
|
+
print(f"service_manager={info.service_manager}")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_project(path: str) -> int:
|
|
34
|
+
target = Path(path).expanduser().resolve()
|
|
35
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
config = target / "do-again.toml"
|
|
37
|
+
if config.exists():
|
|
38
|
+
print(str(config))
|
|
39
|
+
return 0
|
|
40
|
+
config.write_text('[do_again]\ncontrol_branch = "operator-control"\n')
|
|
41
|
+
print(str(config))
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> int:
|
|
46
|
+
parser = argparse.ArgumentParser(prog="do-again")
|
|
47
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
48
|
+
sub.add_parser("doctor")
|
|
49
|
+
sub.add_parser("status")
|
|
50
|
+
init_parser = sub.add_parser("init")
|
|
51
|
+
init_parser.add_argument("path", nargs="?", default=".")
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
if args.command == "doctor":
|
|
54
|
+
return doctor()
|
|
55
|
+
if args.command == "status":
|
|
56
|
+
return status()
|
|
57
|
+
if args.command == "init":
|
|
58
|
+
return init_project(args.path)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
raise SystemExit(main())
|
|
File without changes
|
do_again/core/agent.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
12
|
+
from datetime import timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .executor import LocalExecutor
|
|
17
|
+
from .schema import (
|
|
18
|
+
OperatorError,
|
|
19
|
+
atomic_json,
|
|
20
|
+
read_json,
|
|
21
|
+
request_fingerprint,
|
|
22
|
+
utc_now,
|
|
23
|
+
validate_request,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Agent:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
repo: Path,
|
|
32
|
+
control_worktree: Path,
|
|
33
|
+
branch: str,
|
|
34
|
+
policy_path: Path,
|
|
35
|
+
state_dir: Path,
|
|
36
|
+
):
|
|
37
|
+
self.repo = repo.resolve()
|
|
38
|
+
self.control_worktree = control_worktree.resolve()
|
|
39
|
+
self.branch = branch
|
|
40
|
+
self.policy_path = policy_path.resolve()
|
|
41
|
+
self.state_dir = state_dir.resolve()
|
|
42
|
+
self.policy = read_json(self.policy_path)
|
|
43
|
+
self.executor = LocalExecutor(
|
|
44
|
+
repo=self.repo,
|
|
45
|
+
policy_path=self.policy_path,
|
|
46
|
+
state_dir=self.state_dir,
|
|
47
|
+
)
|
|
48
|
+
self.requests_dir = (
|
|
49
|
+
self.control_worktree / "automation/do_again/requests"
|
|
50
|
+
)
|
|
51
|
+
self.receipts_dir = (
|
|
52
|
+
self.control_worktree / "automation/do_again/receipts"
|
|
53
|
+
)
|
|
54
|
+
self.ledger_dir = self.state_dir / "ledger"
|
|
55
|
+
self.stop_requested = False
|
|
56
|
+
self.started = utc_now()
|
|
57
|
+
self.instance_id = f"{socket.gethostname()}:{os.getpid()}:{int(self.started.timestamp())}"
|
|
58
|
+
self.poll_seconds = max(1.0, float(self.policy.get("poll_seconds", 3)))
|
|
59
|
+
|
|
60
|
+
def git(self, *args: str, timeout: float = 60) -> subprocess.CompletedProcess[str]:
|
|
61
|
+
return subprocess.run(
|
|
62
|
+
["git", "-C", str(self.control_worktree), *args],
|
|
63
|
+
text=True,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def require_git(self, *args: str, timeout: float = 60) -> str:
|
|
69
|
+
proc = self.git(*args, timeout=timeout)
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
raise OperatorError(
|
|
72
|
+
f"git {' '.join(args)} failed rc={proc.returncode}: "
|
|
73
|
+
f"{(proc.stderr or proc.stdout).strip()}"
|
|
74
|
+
)
|
|
75
|
+
return proc.stdout
|
|
76
|
+
|
|
77
|
+
def sync(self) -> None:
|
|
78
|
+
status = self.require_git("status", "--porcelain")
|
|
79
|
+
if status.strip():
|
|
80
|
+
raise OperatorError("operator control worktree is dirty")
|
|
81
|
+
self.require_git("fetch", "--quiet", "origin", self.branch)
|
|
82
|
+
self.require_git("rebase", f"origin/{self.branch}")
|
|
83
|
+
push = self.git("push", "origin", f"HEAD:{self.branch}", timeout=90)
|
|
84
|
+
if push.returncode != 0:
|
|
85
|
+
self.require_git("fetch", "--quiet", "origin", self.branch)
|
|
86
|
+
self.require_git("rebase", f"origin/{self.branch}")
|
|
87
|
+
self.require_git("push", "origin", f"HEAD:{self.branch}", timeout=90)
|
|
88
|
+
|
|
89
|
+
def publish_json(self, relative: Path, value: dict[str, Any], message: str) -> None:
|
|
90
|
+
for attempt in range(4):
|
|
91
|
+
try:
|
|
92
|
+
self.sync()
|
|
93
|
+
target = self.control_worktree / relative
|
|
94
|
+
atomic_json(target, value)
|
|
95
|
+
self.require_git("add", str(relative))
|
|
96
|
+
commit = self.git("commit", "-m", message, timeout=60)
|
|
97
|
+
if commit.returncode != 0:
|
|
98
|
+
combined = f"{commit.stdout}\n{commit.stderr}".lower()
|
|
99
|
+
if "nothing to commit" not in combined:
|
|
100
|
+
raise OperatorError(
|
|
101
|
+
f"git commit failed rc={commit.returncode}: "
|
|
102
|
+
f"{(commit.stderr or commit.stdout).strip()}"
|
|
103
|
+
)
|
|
104
|
+
self.require_git("push", "origin", f"HEAD:{self.branch}", timeout=90)
|
|
105
|
+
return
|
|
106
|
+
except Exception:
|
|
107
|
+
if attempt == 3:
|
|
108
|
+
raise
|
|
109
|
+
time.sleep(1.5 * (attempt + 1))
|
|
110
|
+
|
|
111
|
+
def status_payload(self, state: str, **extra: Any) -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"schema_version": 1,
|
|
114
|
+
"state": state,
|
|
115
|
+
"instance_id": self.instance_id,
|
|
116
|
+
"host": socket.gethostname(),
|
|
117
|
+
"pid": os.getpid(),
|
|
118
|
+
"started_at_utc": self.started.astimezone(timezone.utc).isoformat(),
|
|
119
|
+
"updated_at_utc": utc_now().isoformat(),
|
|
120
|
+
"control_branch": self.branch,
|
|
121
|
+
"repo": str(self.repo),
|
|
122
|
+
"control_worktree": str(self.control_worktree),
|
|
123
|
+
**extra,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def publish_status(self, state: str, **extra: Any) -> None:
|
|
127
|
+
self.publish_json(
|
|
128
|
+
Path("automation/do_again/agent_status.json"),
|
|
129
|
+
self.status_payload(state, **extra),
|
|
130
|
+
f"Do Again {state}",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def ledger_path(self, request_id: str) -> Path:
|
|
134
|
+
return self.ledger_dir / f"{request_id}.json"
|
|
135
|
+
|
|
136
|
+
def receipt_relative(self, request_id: str) -> Path:
|
|
137
|
+
return Path(f"automation/do_again/receipts/{request_id}.json")
|
|
138
|
+
|
|
139
|
+
def receipt_exists(self, request_id: str) -> bool:
|
|
140
|
+
return (self.control_worktree / self.receipt_relative(request_id)).is_file()
|
|
141
|
+
|
|
142
|
+
def local_ledger(self, request_id: str) -> dict[str, Any] | None:
|
|
143
|
+
path = self.ledger_path(request_id)
|
|
144
|
+
if not path.is_file():
|
|
145
|
+
return None
|
|
146
|
+
value = read_json(path)
|
|
147
|
+
return value if isinstance(value, dict) else None
|
|
148
|
+
|
|
149
|
+
def write_ledger(self, request_id: str, value: dict[str, Any]) -> None:
|
|
150
|
+
atomic_json(self.ledger_path(request_id), value)
|
|
151
|
+
|
|
152
|
+
def make_receipt(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
request: dict[str, Any],
|
|
156
|
+
state: str,
|
|
157
|
+
started_at: str,
|
|
158
|
+
result: dict[str, Any] | None = None,
|
|
159
|
+
error: str | None = None,
|
|
160
|
+
traceback_text: str | None = None,
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
return {
|
|
163
|
+
"schema_version": 1,
|
|
164
|
+
"request_id": request.get("request_id"),
|
|
165
|
+
"request_fingerprint": request_fingerprint(request),
|
|
166
|
+
"operation": request.get("operation"),
|
|
167
|
+
"state": state,
|
|
168
|
+
"started_at_utc": started_at,
|
|
169
|
+
"finished_at_utc": utc_now().isoformat(),
|
|
170
|
+
"agent_instance_id": self.instance_id,
|
|
171
|
+
"host": socket.gethostname(),
|
|
172
|
+
"result": result,
|
|
173
|
+
"error": error,
|
|
174
|
+
"traceback": traceback_text,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def result_succeeded(self, payload: dict[str, Any]) -> bool:
|
|
178
|
+
result = payload.get("result")
|
|
179
|
+
if not isinstance(result, dict):
|
|
180
|
+
return True
|
|
181
|
+
if "returncode" in result:
|
|
182
|
+
return result.get("returncode") == 0 and not result.get("timed_out")
|
|
183
|
+
if "retarget" in result:
|
|
184
|
+
retarget = result.get("retarget")
|
|
185
|
+
build = result.get("build")
|
|
186
|
+
return (
|
|
187
|
+
isinstance(build, dict)
|
|
188
|
+
and build.get("returncode") == 0
|
|
189
|
+
and isinstance(retarget, dict)
|
|
190
|
+
and retarget.get("returncode") == 0
|
|
191
|
+
)
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def publish_receipt(self, receipt: dict[str, Any]) -> None:
|
|
195
|
+
request_id = str(receipt["request_id"])
|
|
196
|
+
self.publish_json(
|
|
197
|
+
self.receipt_relative(request_id),
|
|
198
|
+
receipt,
|
|
199
|
+
f"Do Again receipt {request_id}: {receipt['state']}",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def schedule_self_restart(self) -> None:
|
|
203
|
+
label = str(
|
|
204
|
+
self.policy.get("agent_launchd_label", "io.github.tran-steven.do-again")
|
|
205
|
+
).strip()
|
|
206
|
+
prefixes = tuple(str(value) for value in self.policy.get("launchctl_label_prefixes", []))
|
|
207
|
+
if not prefixes or not label.startswith(prefixes):
|
|
208
|
+
raise OperatorError("agent launchd label is not approved")
|
|
209
|
+
target = f"gui/{os.getuid()}/{label}"
|
|
210
|
+
subprocess.Popen(
|
|
211
|
+
["/bin/launchctl", "kickstart", "-k", target],
|
|
212
|
+
stdin=subprocess.DEVNULL,
|
|
213
|
+
stdout=subprocess.DEVNULL,
|
|
214
|
+
stderr=subprocess.DEVNULL,
|
|
215
|
+
start_new_session=True,
|
|
216
|
+
close_fds=True,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def process_path(self, path: Path) -> bool:
|
|
220
|
+
try:
|
|
221
|
+
raw = read_json(path)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
return False
|
|
224
|
+
if not isinstance(raw, dict):
|
|
225
|
+
return False
|
|
226
|
+
request_id = str(raw.get("request_id") or "")
|
|
227
|
+
if not request_id or path.stem != request_id:
|
|
228
|
+
return False
|
|
229
|
+
if self.receipt_exists(request_id):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
ledger = self.local_ledger(request_id)
|
|
233
|
+
if ledger:
|
|
234
|
+
ledger_state = ledger.get("state")
|
|
235
|
+
stored_receipt = ledger.get("receipt")
|
|
236
|
+
if ledger_state == "terminal" and isinstance(stored_receipt, dict):
|
|
237
|
+
self.publish_receipt(stored_receipt)
|
|
238
|
+
return True
|
|
239
|
+
if ledger_state == "started":
|
|
240
|
+
started_at = str(ledger.get("started_at_utc") or utc_now().isoformat())
|
|
241
|
+
receipt = self.make_receipt(
|
|
242
|
+
request=raw,
|
|
243
|
+
state="blocked_ambiguous_replay",
|
|
244
|
+
started_at=started_at,
|
|
245
|
+
error=(
|
|
246
|
+
"A prior agent instance began this request but did not durably record "
|
|
247
|
+
"its completion. Refusing automatic replay."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
self.write_ledger(
|
|
251
|
+
request_id,
|
|
252
|
+
{
|
|
253
|
+
"state": "terminal",
|
|
254
|
+
"request_fingerprint": request_fingerprint(raw),
|
|
255
|
+
"receipt": receipt,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
self.publish_receipt(receipt)
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
started_at = utc_now().isoformat()
|
|
262
|
+
try:
|
|
263
|
+
request = validate_request(
|
|
264
|
+
raw,
|
|
265
|
+
max_ttl_seconds=int(self.policy.get("max_request_ttl_seconds", 3600)),
|
|
266
|
+
)
|
|
267
|
+
self.write_ledger(
|
|
268
|
+
request_id,
|
|
269
|
+
{
|
|
270
|
+
"state": "started",
|
|
271
|
+
"request_fingerprint": request_fingerprint(request),
|
|
272
|
+
"started_at_utc": started_at,
|
|
273
|
+
"agent_instance_id": self.instance_id,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
payload = self.executor.execute(request)
|
|
277
|
+
receipt_state = "succeeded" if self.result_succeeded(payload) else "failed"
|
|
278
|
+
receipt = self.make_receipt(
|
|
279
|
+
request=request,
|
|
280
|
+
state=receipt_state,
|
|
281
|
+
started_at=started_at,
|
|
282
|
+
result=payload,
|
|
283
|
+
)
|
|
284
|
+
except OperatorError as exc:
|
|
285
|
+
receipt = self.make_receipt(
|
|
286
|
+
request=raw,
|
|
287
|
+
state="blocked",
|
|
288
|
+
started_at=started_at,
|
|
289
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
290
|
+
)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
receipt = self.make_receipt(
|
|
293
|
+
request=raw,
|
|
294
|
+
state="error",
|
|
295
|
+
started_at=started_at,
|
|
296
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
297
|
+
traceback_text=traceback.format_exc()[-12000:],
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
self.write_ledger(
|
|
301
|
+
request_id,
|
|
302
|
+
{
|
|
303
|
+
"state": "terminal",
|
|
304
|
+
"request_fingerprint": request_fingerprint(raw),
|
|
305
|
+
"receipt": receipt,
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
self.publish_receipt(receipt)
|
|
309
|
+
try:
|
|
310
|
+
self.publish_status(
|
|
311
|
+
"ready",
|
|
312
|
+
last_request_id=request_id,
|
|
313
|
+
last_request_state=receipt["state"],
|
|
314
|
+
)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
payload_result = receipt.get("result")
|
|
318
|
+
if (
|
|
319
|
+
receipt.get("state") == "succeeded"
|
|
320
|
+
and isinstance(payload_result, dict)
|
|
321
|
+
and isinstance(payload_result.get("result"), dict)
|
|
322
|
+
and payload_result["result"].get("restart_after_receipt") is True
|
|
323
|
+
):
|
|
324
|
+
self.schedule_self_restart()
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
def request_paths(self) -> list[Path]:
|
|
328
|
+
self.requests_dir.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
self.receipts_dir.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
values = []
|
|
331
|
+
for path in self.requests_dir.glob("*.json"):
|
|
332
|
+
try:
|
|
333
|
+
value = read_json(path)
|
|
334
|
+
except Exception:
|
|
335
|
+
continue
|
|
336
|
+
issued = str(value.get("issued_at_utc") or "") if isinstance(value, dict) else ""
|
|
337
|
+
values.append((issued, path.name, path))
|
|
338
|
+
values.sort()
|
|
339
|
+
return [path for _, _, path in values]
|
|
340
|
+
|
|
341
|
+
def run(self, once: bool = False) -> int:
|
|
342
|
+
self.ledger_dir.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
try:
|
|
344
|
+
self.publish_status("ready")
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
print(f"do-again initial status publish failed: {exc}", file=sys.stderr, flush=True)
|
|
347
|
+
while not self.stop_requested:
|
|
348
|
+
try:
|
|
349
|
+
self.sync()
|
|
350
|
+
did_work = False
|
|
351
|
+
for path in self.request_paths():
|
|
352
|
+
if self.stop_requested:
|
|
353
|
+
break
|
|
354
|
+
if self.process_path(path):
|
|
355
|
+
did_work = True
|
|
356
|
+
if once:
|
|
357
|
+
return 0
|
|
358
|
+
if not did_work:
|
|
359
|
+
time.sleep(self.poll_seconds)
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
print(f"do-again loop error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
|
362
|
+
if once:
|
|
363
|
+
return 1
|
|
364
|
+
time.sleep(max(3.0, self.poll_seconds))
|
|
365
|
+
try:
|
|
366
|
+
self.publish_status("stopped")
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def parse_args() -> argparse.Namespace:
|
|
373
|
+
parser = argparse.ArgumentParser()
|
|
374
|
+
parser.add_argument("--repo", required=True)
|
|
375
|
+
parser.add_argument("--control-worktree", required=True)
|
|
376
|
+
parser.add_argument("--branch", default="operator-control")
|
|
377
|
+
parser.add_argument("--policy", required=True)
|
|
378
|
+
parser.add_argument("--state-dir", required=True)
|
|
379
|
+
parser.add_argument("--once", action="store_true")
|
|
380
|
+
return parser.parse_args()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main() -> int:
|
|
384
|
+
args = parse_args()
|
|
385
|
+
agent = Agent(
|
|
386
|
+
repo=Path(args.repo),
|
|
387
|
+
control_worktree=Path(args.control_worktree),
|
|
388
|
+
branch=args.branch,
|
|
389
|
+
policy_path=Path(args.policy),
|
|
390
|
+
state_dir=Path(args.state_dir),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def stop_handler(signum: int, frame: Any) -> None:
|
|
394
|
+
agent.stop_requested = True
|
|
395
|
+
|
|
396
|
+
signal.signal(signal.SIGTERM, stop_handler)
|
|
397
|
+
signal.signal(signal.SIGINT, stop_handler)
|
|
398
|
+
return agent.run(once=args.once)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
raise SystemExit(main())
|