shopping-cli 3.0.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.
- shopping_cli/__init__.py +3 -0
- shopping_cli/agents/__init__.py +1 -0
- shopping_cli/agents/agent_cli.py +31 -0
- shopping_cli/agents/buyer_cli.py +163 -0
- shopping_cli/agents/merchant_agent.py +263 -0
- shopping_cli/agents/merchant_daemon.py +872 -0
- shopping_cli/agents/tools.py +488 -0
- shopping_cli/api/__init__.py +1 -0
- shopping_cli/api/app.py +1353 -0
- shopping_cli/api/auth.py +125 -0
- shopping_cli/api/fallback_asgi.py +219 -0
- shopping_cli/api/handlers/__init__.py +1 -0
- shopping_cli/api/handlers/agents.py +277 -0
- shopping_cli/api/handlers/audit.py +97 -0
- shopping_cli/api/handlers/buyer.py +291 -0
- shopping_cli/api/handlers/catalog.py +392 -0
- shopping_cli/api/handlers/common.py +76 -0
- shopping_cli/api/handlers/conversations.py +172 -0
- shopping_cli/api/handlers/erp.py +127 -0
- shopping_cli/api/handlers/human_review.py +293 -0
- shopping_cli/api/handlers/listings_projection.py +42 -0
- shopping_cli/api/handlers/negotiation.py +121 -0
- shopping_cli/api/idempotency.py +291 -0
- shopping_cli/api/limits.py +59 -0
- shopping_cli/api/route_registry.py +105 -0
- shopping_cli/api/routes_agents.py +13 -0
- shopping_cli/api/routes_conversations.py +13 -0
- shopping_cli/api/routes_marketplace.py +13 -0
- shopping_cli/api/routes_merchants.py +13 -0
- shopping_cli/api/server.py +31 -0
- shopping_cli/cli.py +897 -0
- shopping_cli/cli_agent_commands.py +386 -0
- shopping_cli/cli_buyer_commands.py +231 -0
- shopping_cli/cli_catalog_commands.py +281 -0
- shopping_cli/cli_common.py +135 -0
- shopping_cli/cli_conversation_commands.py +167 -0
- shopping_cli/cli_erp_commands.py +60 -0
- shopping_cli/cli_listing_commands.py +35 -0
- shopping_cli/commerce/__init__.py +6 -0
- shopping_cli/commerce/adapters.py +310 -0
- shopping_cli/commerce/data_source.py +95 -0
- shopping_cli/config.py +262 -0
- shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +47 -0
- shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +120 -0
- shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +52 -0
- shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +172 -0
- shopping_cli/core/__init__.py +1 -0
- shopping_cli/core/catalog.py +1281 -0
- shopping_cli/core/channels.py +329 -0
- shopping_cli/core/conversations.py +502 -0
- shopping_cli/core/delivery.py +7 -0
- shopping_cli/core/errors.py +43 -0
- shopping_cli/core/harness.py +345 -0
- shopping_cli/core/limits.py +137 -0
- shopping_cli/core/negotiation.py +273 -0
- shopping_cli/core/policies.py +356 -0
- shopping_cli/core/risk.py +101 -0
- shopping_cli/core/tokens.py +33 -0
- shopping_cli/data_sources/__init__.py +12 -0
- shopping_cli/data_sources/erp_source.py +440 -0
- shopping_cli/db/__init__.py +1 -0
- shopping_cli/db/migrations.py +415 -0
- shopping_cli/db/models.py +325 -0
- shopping_cli/db/provenance.py +21 -0
- shopping_cli/db/session.py +108 -0
- shopping_cli/http_client.py +149 -0
- shopping_cli/listings/__init__.py +4 -0
- shopping_cli/listings/projection.py +148 -0
- shopping_cli/services/__init__.py +1 -0
- shopping_cli/services/agents.py +244 -0
- shopping_cli/services/audit.py +34 -0
- shopping_cli/services/buyer_bootstrap.py +23 -0
- shopping_cli/services/conversations.py +209 -0
- shopping_cli/services/human_review.py +165 -0
- shopping_cli/services/negotiation.py +929 -0
- shopping_cli/services/tokens.py +318 -0
- shopping_cli-3.0.0.dist-info/METADATA +219 -0
- shopping_cli-3.0.0.dist-info/RECORD +82 -0
- shopping_cli-3.0.0.dist-info/WHEEL +5 -0
- shopping_cli-3.0.0.dist-info/entry_points.txt +4 -0
- shopping_cli-3.0.0.dist-info/licenses/LICENSE +201 -0
- shopping_cli-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""Local lifecycle management for resident merchant-agent processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import fcntl
|
|
19
|
+
except ImportError: # pragma: no cover - Windows
|
|
20
|
+
fcntl = None # type: ignore[assignment]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import msvcrt
|
|
24
|
+
except ImportError: # pragma: no cover - POSIX
|
|
25
|
+
msvcrt = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import psutil
|
|
29
|
+
except ImportError: # pragma: no cover - optional runtime dependency
|
|
30
|
+
psutil = None
|
|
31
|
+
|
|
32
|
+
from shopping_cli.agents import merchant_agent
|
|
33
|
+
from shopping_cli.db.session import db_session, decode_json, now_iso
|
|
34
|
+
from shopping_cli.core.limits import safe_non_negative_int, safe_positive_float, safe_non_negative_float_with_max as safe_non_negative_float
|
|
35
|
+
|
|
36
|
+
DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "shopping-cli"
|
|
37
|
+
MAX_AGENT_INTERVAL_SECONDS = 3600.0
|
|
38
|
+
MAX_AGENT_STOP_TIMEOUT_SECONDS = 300.0
|
|
39
|
+
MAX_AGENT_LOG_TAIL = 1000
|
|
40
|
+
MAX_AGENT_LOG_BYTES = 5 * 1024 * 1024
|
|
41
|
+
MAX_AGENT_LOG_BACKUPS = 3
|
|
42
|
+
MAX_AGENT_ERROR_BACKOFF_SECONDS = 60.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def state_dir_from(value: str | Path | None = None) -> Path:
|
|
46
|
+
return Path(value or os.environ.get("SHOPPING_CLI_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def safe_merchant_id(merchant_id: str) -> str:
|
|
50
|
+
raw = str(merchant_id or "")
|
|
51
|
+
slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._-")
|
|
52
|
+
slug = (slug or "merchant")[:64]
|
|
53
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
54
|
+
return f"{slug}-{digest}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def agent_paths(merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Path]:
|
|
58
|
+
root = state_dir_from(state_dir)
|
|
59
|
+
safe_id = safe_merchant_id(merchant_id)
|
|
60
|
+
return {
|
|
61
|
+
"state_dir": root,
|
|
62
|
+
"pid_file": root / "agents" / f"{safe_id}.pid",
|
|
63
|
+
"state_file": root / "agents" / f"{safe_id}.state.json",
|
|
64
|
+
"stop_file": root / "agents" / f"{safe_id}.stop",
|
|
65
|
+
"log_file": root / "logs" / f"{safe_id}.log",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_agent_dirs(paths: dict[str, Path]) -> None:
|
|
70
|
+
paths["pid_file"].parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
paths["state_file"].parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
paths["log_file"].parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rotate_agent_log(log_file: Path) -> bool:
|
|
76
|
+
try:
|
|
77
|
+
if log_file.stat().st_size <= MAX_AGENT_LOG_BYTES:
|
|
78
|
+
return False
|
|
79
|
+
except OSError:
|
|
80
|
+
return False
|
|
81
|
+
oldest = log_file.with_suffix(log_file.suffix + f".{MAX_AGENT_LOG_BACKUPS}")
|
|
82
|
+
if oldest.exists():
|
|
83
|
+
oldest.unlink()
|
|
84
|
+
for index in range(MAX_AGENT_LOG_BACKUPS - 1, 0, -1):
|
|
85
|
+
source = log_file.with_suffix(log_file.suffix + f".{index}")
|
|
86
|
+
if source.exists():
|
|
87
|
+
source.replace(log_file.with_suffix(log_file.suffix + f".{index + 1}"))
|
|
88
|
+
log_file.replace(log_file.with_suffix(log_file.suffix + ".1"))
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _redirect_stdout_to(log_path: Path) -> None:
|
|
93
|
+
"""Repoint stdout at a freshly rotated log file.
|
|
94
|
+
|
|
95
|
+
Renaming the live log leaves the daemon writing into the renamed backup,
|
|
96
|
+
so after an in-process rotation stdout must be attached to the new file.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
target_fd = sys.stdout.fileno()
|
|
100
|
+
except (OSError, ValueError):
|
|
101
|
+
return
|
|
102
|
+
fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
103
|
+
try:
|
|
104
|
+
os.dup2(fd, target_fd)
|
|
105
|
+
finally:
|
|
106
|
+
os.close(fd)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tail_log_lines(path: Path, count: int, max_bytes: int = 2 * 1024 * 1024) -> list[str]:
|
|
110
|
+
try:
|
|
111
|
+
with path.open("rb") as handle:
|
|
112
|
+
handle.seek(0, os.SEEK_END)
|
|
113
|
+
remaining = handle.tell()
|
|
114
|
+
chunks: list[bytes] = []
|
|
115
|
+
newlines = 0
|
|
116
|
+
read_bytes = 0
|
|
117
|
+
while remaining > 0 and newlines <= count and read_bytes < max_bytes:
|
|
118
|
+
size = min(65536, remaining, max_bytes - read_bytes)
|
|
119
|
+
remaining -= size
|
|
120
|
+
handle.seek(remaining)
|
|
121
|
+
chunk = handle.read(size)
|
|
122
|
+
chunks.append(chunk)
|
|
123
|
+
newlines += chunk.count(b"\n")
|
|
124
|
+
read_bytes += len(chunk)
|
|
125
|
+
return b"".join(reversed(chunks)).decode("utf-8").splitlines()[-count:]
|
|
126
|
+
except (OSError, UnicodeDecodeError):
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_json(path: Path, default: Any) -> Any:
|
|
131
|
+
try:
|
|
132
|
+
decoded = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
134
|
+
return default
|
|
135
|
+
if isinstance(default, dict) and not isinstance(decoded, dict):
|
|
136
|
+
return default
|
|
137
|
+
return decoded
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def safe_replied_count(value: Any) -> int:
|
|
144
|
+
if not isinstance(value, list):
|
|
145
|
+
return 0
|
|
146
|
+
return len(value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def permanent_agent_error(error: str) -> bool:
|
|
150
|
+
lowered = str(error or "").lower()
|
|
151
|
+
return any(marker in lowered for marker in ("invalid authorization", "revoked authorization", "expired authorization", "token required", "unknown merchant"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_json_atomic(path: Path, value: Any) -> None:
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
tmp = path.with_name(f"{path.name}.tmp")
|
|
157
|
+
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
|
158
|
+
tmp.replace(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _PidFileLock:
|
|
162
|
+
"""Advisory exclusive lock on a companion lock file for the PID file.
|
|
163
|
+
|
|
164
|
+
Uses ``fcntl`` on POSIX and ``msvcrt.locking`` on Windows.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, pid_file: Path) -> None:
|
|
168
|
+
self.lock_path = Path(pid_file).with_suffix(pid_file.suffix + ".lock")
|
|
169
|
+
self._fd: int | None = None
|
|
170
|
+
|
|
171
|
+
def __enter__(self) -> "_PidFileLock":
|
|
172
|
+
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
self._fd = os.open(
|
|
174
|
+
str(self.lock_path),
|
|
175
|
+
os.O_RDWR | os.O_CREAT,
|
|
176
|
+
0o644,
|
|
177
|
+
)
|
|
178
|
+
if fcntl is not None:
|
|
179
|
+
try:
|
|
180
|
+
fcntl.flock(self._fd, fcntl.LOCK_EX)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise RuntimeError(f"Failed to acquire PID file lock ({self.lock_path}): {exc}") from exc
|
|
183
|
+
elif msvcrt is not None: # pragma: no cover - Windows
|
|
184
|
+
try:
|
|
185
|
+
if os.fstat(self._fd).st_size == 0:
|
|
186
|
+
os.write(self._fd, b"0")
|
|
187
|
+
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
188
|
+
msvcrt.locking(self._fd, msvcrt.LK_LOCK, 1)
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise RuntimeError(f"Failed to acquire PID file lock ({self.lock_path}): {exc}") from exc
|
|
191
|
+
else: # pragma: no cover - unsupported platform
|
|
192
|
+
raise RuntimeError("No supported PID file locking implementation is available")
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
196
|
+
if self._fd is None:
|
|
197
|
+
return
|
|
198
|
+
if fcntl is not None:
|
|
199
|
+
try:
|
|
200
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
elif msvcrt is not None: # pragma: no cover - Windows
|
|
204
|
+
try:
|
|
205
|
+
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
206
|
+
msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
os.close(self._fd)
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
self._fd = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_process_running(pid: int | None) -> bool:
|
|
217
|
+
if not pid or pid <= 0:
|
|
218
|
+
return False
|
|
219
|
+
if psutil is None:
|
|
220
|
+
return _is_process_running_legacy(pid)
|
|
221
|
+
try:
|
|
222
|
+
if not psutil.pid_exists(pid):
|
|
223
|
+
return False
|
|
224
|
+
return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
|
225
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_process_running_legacy(pid: int) -> bool:
|
|
230
|
+
"""Unix-only fallback when psutil is unavailable."""
|
|
231
|
+
try:
|
|
232
|
+
os.kill(pid, 0)
|
|
233
|
+
except ProcessLookupError:
|
|
234
|
+
return False
|
|
235
|
+
except PermissionError:
|
|
236
|
+
return True
|
|
237
|
+
try:
|
|
238
|
+
status = subprocess.run(
|
|
239
|
+
["ps", "-o", "stat=", "-p", str(pid)],
|
|
240
|
+
capture_output=True,
|
|
241
|
+
text=True,
|
|
242
|
+
timeout=1,
|
|
243
|
+
)
|
|
244
|
+
except (OSError, subprocess.SubprocessError):
|
|
245
|
+
return True
|
|
246
|
+
state = status.stdout.strip().upper()
|
|
247
|
+
if status.returncode != 0 or not state:
|
|
248
|
+
# os.kill(pid, 0) above proved that a process currently owns the PID.
|
|
249
|
+
# Some sandboxes deny `ps`; cooperative stop remains safe because the
|
|
250
|
+
# fallback never sends a signal without psutil identity verification.
|
|
251
|
+
return True
|
|
252
|
+
return "Z" not in state
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def process_identity(pid: int) -> dict[str, Any] | None:
|
|
256
|
+
if psutil is None or not is_process_running(pid):
|
|
257
|
+
return None
|
|
258
|
+
try:
|
|
259
|
+
process = psutil.Process(pid)
|
|
260
|
+
return {
|
|
261
|
+
"create_time": float(process.create_time()),
|
|
262
|
+
"executable": str(process.exe() or ""),
|
|
263
|
+
"cmdline": [str(item) for item in process.cmdline()],
|
|
264
|
+
}
|
|
265
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError, ValueError):
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def wait_for_process_identity(pid: int, command: list[str], timeout: float = 0.5) -> dict[str, Any] | None:
|
|
270
|
+
"""Wait briefly for a newly spawned Python process to finish ``exec``."""
|
|
271
|
+
deadline = time.monotonic() + timeout
|
|
272
|
+
last_identity: dict[str, Any] | None = None
|
|
273
|
+
ready_since: float | None = None
|
|
274
|
+
required = ["shopping_cli.cli", "agent", "run", "--merchant"]
|
|
275
|
+
while time.monotonic() < deadline:
|
|
276
|
+
identity = process_identity(pid)
|
|
277
|
+
if identity is not None:
|
|
278
|
+
last_identity = identity
|
|
279
|
+
cmdline = [str(item) for item in identity.get("cmdline") or []]
|
|
280
|
+
if all(item in cmdline for item in required):
|
|
281
|
+
now = time.monotonic()
|
|
282
|
+
if ready_since is None:
|
|
283
|
+
ready_since = now
|
|
284
|
+
elif now - ready_since >= 0.1:
|
|
285
|
+
return identity
|
|
286
|
+
else:
|
|
287
|
+
ready_since = None
|
|
288
|
+
elif not is_process_running(pid):
|
|
289
|
+
break
|
|
290
|
+
time.sleep(0.01)
|
|
291
|
+
return last_identity
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def pid_record_matches_process(pid_record: dict[str, Any], merchant_id: str) -> bool:
|
|
295
|
+
"""Verify that a PID record still identifies this exact daemon process."""
|
|
296
|
+
pid = safe_non_negative_int(pid_record.get("pid"))
|
|
297
|
+
launch_token = str(pid_record.get("launch_token") or "")
|
|
298
|
+
recorded_create_time = pid_record.get("create_time", 0.0)
|
|
299
|
+
if not pid or not launch_token or str(pid_record.get("merchant_id") or "") != str(merchant_id):
|
|
300
|
+
return False
|
|
301
|
+
recorded_command = [str(item) for item in (pid_record.get("command") or [])]
|
|
302
|
+
required = ["shopping_cli.cli", "agent", "run", "--merchant", str(merchant_id)]
|
|
303
|
+
if not all(item in recorded_command for item in required):
|
|
304
|
+
return False
|
|
305
|
+
if psutil is None:
|
|
306
|
+
return is_process_running(pid)
|
|
307
|
+
identity = process_identity(pid)
|
|
308
|
+
if identity is None:
|
|
309
|
+
return False
|
|
310
|
+
try:
|
|
311
|
+
if abs(float(recorded_create_time) - float(identity["create_time"])) > 0.01:
|
|
312
|
+
return False
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
return False
|
|
315
|
+
recorded_executable = str(pid_record.get("executable") or "")
|
|
316
|
+
if recorded_executable and os.path.realpath(recorded_executable) != os.path.realpath(identity["executable"]):
|
|
317
|
+
return False
|
|
318
|
+
cmdline = list(identity.get("cmdline") or [])
|
|
319
|
+
if not all(item in cmdline for item in required):
|
|
320
|
+
return False
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def state_matches_pid_record(state: dict[str, Any], pid_record: dict[str, Any], merchant_id: str) -> bool:
|
|
325
|
+
launch_token = str(pid_record.get("launch_token") or "")
|
|
326
|
+
if not launch_token or str(state.get("launch_token") or "") != launch_token:
|
|
327
|
+
return False
|
|
328
|
+
if str(state.get("merchant_id") or "") != str(merchant_id):
|
|
329
|
+
return False
|
|
330
|
+
state_pid = safe_non_negative_int(state.get("pid"))
|
|
331
|
+
record_pid = safe_non_negative_int(pid_record.get("pid"))
|
|
332
|
+
if state_pid and state_pid != record_pid:
|
|
333
|
+
return False
|
|
334
|
+
try:
|
|
335
|
+
updated_at = datetime.fromisoformat(str(state.get("updated_at") or ""))
|
|
336
|
+
interval = safe_positive_float(pid_record.get("interval"), 3.0, maximum=MAX_AGENT_INTERVAL_SECONDS)
|
|
337
|
+
max_age = max(interval * 3, 60.0)
|
|
338
|
+
if (datetime.now() - updated_at).total_seconds() > max_age:
|
|
339
|
+
return False
|
|
340
|
+
except (TypeError, ValueError):
|
|
341
|
+
return False
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def read_agent_heartbeat(db_path: str | Path, merchant_id: str) -> dict[str, Any]:
|
|
346
|
+
agent_id = f"shopping-cli-merchant-agent:{merchant_id}"
|
|
347
|
+
with db_session(db_path) as conn:
|
|
348
|
+
row = conn.execute("select * from agents where id = ?", (agent_id,)).fetchone()
|
|
349
|
+
if row is None:
|
|
350
|
+
return {
|
|
351
|
+
"id": agent_id,
|
|
352
|
+
"type": "merchant",
|
|
353
|
+
"owner_id": merchant_id,
|
|
354
|
+
"status": "away",
|
|
355
|
+
"capabilities": [],
|
|
356
|
+
"last_seen_at": None,
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
"id": row["id"],
|
|
360
|
+
"type": row["type"],
|
|
361
|
+
"owner_id": row["owner_id"],
|
|
362
|
+
"status": row["status"],
|
|
363
|
+
"capabilities": decode_json(row["capabilities_json"], []),
|
|
364
|
+
"last_seen_at": row["last_seen_at"],
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def write_state(
|
|
369
|
+
state_file: Path,
|
|
370
|
+
merchant_id: str,
|
|
371
|
+
running: bool,
|
|
372
|
+
counters: dict[str, int],
|
|
373
|
+
last_error: str | None = None,
|
|
374
|
+
pid: int | None = None,
|
|
375
|
+
extra: dict[str, Any] | None = None,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
state = {
|
|
378
|
+
"merchant_id": merchant_id,
|
|
379
|
+
"running": running,
|
|
380
|
+
"pid": pid,
|
|
381
|
+
"updated_at": now_iso(),
|
|
382
|
+
"counters": counters,
|
|
383
|
+
"last_error": last_error,
|
|
384
|
+
}
|
|
385
|
+
if extra:
|
|
386
|
+
state.update(extra)
|
|
387
|
+
write_json_atomic(state_file, state)
|
|
388
|
+
return state
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def start_agent(
|
|
392
|
+
db_path: str | Path,
|
|
393
|
+
merchant_id: str,
|
|
394
|
+
interval: float = 3.0,
|
|
395
|
+
state_dir: str | Path | None = None,
|
|
396
|
+
api_url: str = "",
|
|
397
|
+
agent_token: str = "",
|
|
398
|
+
merchant_token: str = "",
|
|
399
|
+
host: str = "",
|
|
400
|
+
session_id: str = "",
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
api_url = str(api_url or "").strip()
|
|
403
|
+
agent_token = str(agent_token or "").strip()
|
|
404
|
+
merchant_token = str(merchant_token or "").strip()
|
|
405
|
+
host = str(host or "").strip()
|
|
406
|
+
session_id = str(session_id or "").strip()
|
|
407
|
+
mode = "api" if api_url else "sqlite"
|
|
408
|
+
if api_url and not (agent_token or merchant_token):
|
|
409
|
+
raise SystemExit("--merchant-token or --agent-token is required with --api-url")
|
|
410
|
+
interval = safe_positive_float(interval, 3.0, maximum=MAX_AGENT_INTERVAL_SECONDS)
|
|
411
|
+
|
|
412
|
+
paths = agent_paths(merchant_id, state_dir)
|
|
413
|
+
ensure_agent_dirs(paths)
|
|
414
|
+
|
|
415
|
+
with _PidFileLock(paths["pid_file"]):
|
|
416
|
+
pid_record = read_json(paths["pid_file"], {})
|
|
417
|
+
existing_pid = safe_non_negative_int(pid_record.get("pid"))
|
|
418
|
+
existing_state = read_json(paths["state_file"], {})
|
|
419
|
+
existing_matches = (
|
|
420
|
+
state_matches_pid_record(existing_state, pid_record, merchant_id)
|
|
421
|
+
and pid_record_matches_process(pid_record, merchant_id)
|
|
422
|
+
)
|
|
423
|
+
stale_replaced = bool(existing_pid and not existing_matches)
|
|
424
|
+
if existing_pid and existing_matches:
|
|
425
|
+
raise SystemExit(f"Agent already running for merchant {merchant_id}: pid {existing_pid}")
|
|
426
|
+
if paths["stop_file"].exists():
|
|
427
|
+
paths["stop_file"].unlink()
|
|
428
|
+
|
|
429
|
+
if not api_url:
|
|
430
|
+
with db_session(db_path) as conn:
|
|
431
|
+
merchant_agent.heartbeat(conn, merchant_id, status="online")
|
|
432
|
+
|
|
433
|
+
rotate_agent_log(paths["log_file"])
|
|
434
|
+
|
|
435
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
436
|
+
command = [
|
|
437
|
+
sys.executable,
|
|
438
|
+
"-m",
|
|
439
|
+
"shopping_cli.cli",
|
|
440
|
+
"--db",
|
|
441
|
+
str(Path(db_path).expanduser()),
|
|
442
|
+
"agent",
|
|
443
|
+
"run",
|
|
444
|
+
"--merchant",
|
|
445
|
+
merchant_id,
|
|
446
|
+
"--interval",
|
|
447
|
+
str(interval),
|
|
448
|
+
"--format",
|
|
449
|
+
"json",
|
|
450
|
+
"--state-file",
|
|
451
|
+
str(paths["state_file"]),
|
|
452
|
+
"--stop-file",
|
|
453
|
+
str(paths["stop_file"]),
|
|
454
|
+
]
|
|
455
|
+
if host:
|
|
456
|
+
command.extend(["--host", host])
|
|
457
|
+
if session_id:
|
|
458
|
+
command.extend(["--session-id", session_id])
|
|
459
|
+
env = os.environ.copy()
|
|
460
|
+
launch_token = secrets.token_urlsafe(24)
|
|
461
|
+
env["SHOPPING_CLI_STATE_DIR"] = str(paths["state_dir"])
|
|
462
|
+
env["SHOPPING_AGENT_LAUNCH_TOKEN"] = launch_token
|
|
463
|
+
if api_url:
|
|
464
|
+
env["SHOPPING_MARKETPLACE_API_URL"] = api_url
|
|
465
|
+
if host:
|
|
466
|
+
env["SHOPPING_AGENT_HOST"] = host
|
|
467
|
+
if session_id:
|
|
468
|
+
env["SHOPPING_AGENT_SESSION_ID"] = session_id
|
|
469
|
+
if agent_token:
|
|
470
|
+
env["SHOPPING_AGENT_TOKEN"] = agent_token
|
|
471
|
+
env.pop("SHOPPING_MERCHANT_TOKEN", None)
|
|
472
|
+
elif merchant_token:
|
|
473
|
+
env["SHOPPING_MERCHANT_TOKEN"] = merchant_token
|
|
474
|
+
env.pop("SHOPPING_AGENT_TOKEN", None)
|
|
475
|
+
with paths["log_file"].open("ab", buffering=0) as log:
|
|
476
|
+
process = subprocess.Popen(
|
|
477
|
+
command,
|
|
478
|
+
stdin=subprocess.DEVNULL,
|
|
479
|
+
stdout=log,
|
|
480
|
+
stderr=subprocess.STDOUT,
|
|
481
|
+
cwd=str(repo_root),
|
|
482
|
+
env=env,
|
|
483
|
+
start_new_session=True,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
started_at = now_iso()
|
|
487
|
+
identity = wait_for_process_identity(process.pid, command) or {
|
|
488
|
+
"create_time": 0.0,
|
|
489
|
+
"executable": "",
|
|
490
|
+
"cmdline": command,
|
|
491
|
+
}
|
|
492
|
+
pid_payload = {
|
|
493
|
+
"pid": process.pid,
|
|
494
|
+
"create_time": identity["create_time"],
|
|
495
|
+
"executable": identity["executable"],
|
|
496
|
+
"cmdline": identity["cmdline"],
|
|
497
|
+
"launch_token": launch_token,
|
|
498
|
+
"merchant_id": merchant_id,
|
|
499
|
+
"db_path": str(Path(db_path).expanduser()),
|
|
500
|
+
"interval": interval,
|
|
501
|
+
"mode": mode,
|
|
502
|
+
"api_url": api_url,
|
|
503
|
+
"host": host,
|
|
504
|
+
"session_id": session_id,
|
|
505
|
+
"started_at": started_at,
|
|
506
|
+
"command": command,
|
|
507
|
+
"log_file": str(paths["log_file"]),
|
|
508
|
+
"state_file": str(paths["state_file"]),
|
|
509
|
+
"stop_file": str(paths["stop_file"]),
|
|
510
|
+
}
|
|
511
|
+
write_json_atomic(paths["pid_file"], pid_payload)
|
|
512
|
+
|
|
513
|
+
write_state(
|
|
514
|
+
paths["state_file"],
|
|
515
|
+
merchant_id,
|
|
516
|
+
running=True,
|
|
517
|
+
counters={"checked": 0, "replied": 0},
|
|
518
|
+
pid=process.pid,
|
|
519
|
+
extra={
|
|
520
|
+
"started_at": started_at,
|
|
521
|
+
"mode": mode,
|
|
522
|
+
"api_url": api_url,
|
|
523
|
+
"host": host,
|
|
524
|
+
"session_id": session_id,
|
|
525
|
+
"launch_token": launch_token,
|
|
526
|
+
},
|
|
527
|
+
)
|
|
528
|
+
return {
|
|
529
|
+
"ok": True,
|
|
530
|
+
"merchant_id": merchant_id,
|
|
531
|
+
"pid": process.pid,
|
|
532
|
+
"running": True,
|
|
533
|
+
"mode": mode,
|
|
534
|
+
"api_url": api_url,
|
|
535
|
+
"host": host,
|
|
536
|
+
"session_id": session_id,
|
|
537
|
+
"stale_replaced": stale_replaced,
|
|
538
|
+
"pid_file": str(paths["pid_file"]),
|
|
539
|
+
"state_file": str(paths["state_file"]),
|
|
540
|
+
"stop_file": str(paths["stop_file"]),
|
|
541
|
+
"log_file": str(paths["log_file"]),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def stop_agent(
|
|
546
|
+
db_path: str | Path,
|
|
547
|
+
merchant_id: str,
|
|
548
|
+
state_dir: str | Path | None = None,
|
|
549
|
+
timeout: float = 5.0,
|
|
550
|
+
) -> dict[str, Any]:
|
|
551
|
+
timeout = safe_non_negative_float(timeout, 5.0, maximum=MAX_AGENT_STOP_TIMEOUT_SECONDS)
|
|
552
|
+
paths = agent_paths(merchant_id, state_dir)
|
|
553
|
+
with _PidFileLock(paths["pid_file"]):
|
|
554
|
+
pid_record = read_json(paths["pid_file"], {})
|
|
555
|
+
pid = safe_non_negative_int(pid_record.get("pid"))
|
|
556
|
+
mode = str(pid_record.get("mode") or "sqlite")
|
|
557
|
+
host = str(pid_record.get("host") or "")
|
|
558
|
+
session_id = str(pid_record.get("session_id") or "")
|
|
559
|
+
api_url = str(pid_record.get("api_url") or "")
|
|
560
|
+
launch_token = str(pid_record.get("launch_token") or "")
|
|
561
|
+
state_before_stop = read_json(paths["state_file"], {})
|
|
562
|
+
state_launch_token = str(state_before_stop.get("launch_token") or "")
|
|
563
|
+
was_running = bool(
|
|
564
|
+
launch_token
|
|
565
|
+
and state_launch_token == launch_token
|
|
566
|
+
and state_matches_pid_record(state_before_stop, pid_record, merchant_id)
|
|
567
|
+
and pid_record_matches_process(pid_record, merchant_id)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if was_running:
|
|
571
|
+
paths["stop_file"].parent.mkdir(parents=True, exist_ok=True)
|
|
572
|
+
paths["stop_file"].write_text(launch_token, encoding="utf-8")
|
|
573
|
+
if psutil is not None:
|
|
574
|
+
try:
|
|
575
|
+
os.kill(pid, signal.SIGTERM)
|
|
576
|
+
except PermissionError:
|
|
577
|
+
pass
|
|
578
|
+
deadline = time.time() + timeout
|
|
579
|
+
while time.time() < deadline:
|
|
580
|
+
state = read_json(paths["state_file"], {})
|
|
581
|
+
if state.get("running") is False or not pid_record_matches_process(pid_record, merchant_id):
|
|
582
|
+
break
|
|
583
|
+
time.sleep(0.1)
|
|
584
|
+
# SIGKILL 兜底:SIGTERM 被阻塞的处理中进程(如卡在 HTTP 调用,
|
|
585
|
+
# 默认 5s 内无法响应)停不掉——强杀并短等待。
|
|
586
|
+
if pid_record_matches_process(pid_record, merchant_id):
|
|
587
|
+
try:
|
|
588
|
+
os.kill(pid, signal.SIGKILL)
|
|
589
|
+
except (PermissionError, ProcessLookupError):
|
|
590
|
+
pass
|
|
591
|
+
time.sleep(0.2)
|
|
592
|
+
|
|
593
|
+
with _PidFileLock(paths["pid_file"]):
|
|
594
|
+
state_after_stop = read_json(paths["state_file"], {})
|
|
595
|
+
current_record = read_json(paths["pid_file"], {})
|
|
596
|
+
same_launch = bool(
|
|
597
|
+
launch_token
|
|
598
|
+
and str(current_record.get("launch_token") or "") == launch_token
|
|
599
|
+
and safe_non_negative_int(current_record.get("pid")) == pid
|
|
600
|
+
)
|
|
601
|
+
# A different launch token means a new daemon took over the pid/state
|
|
602
|
+
# files while this stop was in flight; it must remain untouched.
|
|
603
|
+
replaced_by_new_launch = bool(str(current_record.get("launch_token") or "")) and not same_launch
|
|
604
|
+
running = same_launch and pid_record_matches_process(current_record, merchant_id) and state_after_stop.get("running") is not False
|
|
605
|
+
if not running and same_launch and paths["pid_file"].exists():
|
|
606
|
+
paths["pid_file"].unlink()
|
|
607
|
+
|
|
608
|
+
if mode != "api":
|
|
609
|
+
with db_session(db_path) as conn:
|
|
610
|
+
merchant_agent.heartbeat(conn, merchant_id, status="away")
|
|
611
|
+
|
|
612
|
+
previous = read_json(paths["state_file"], {})
|
|
613
|
+
counters = previous.get("counters") or {"checked": 0, "replied": 0}
|
|
614
|
+
if not running and not replaced_by_new_launch:
|
|
615
|
+
write_state(
|
|
616
|
+
paths["state_file"],
|
|
617
|
+
merchant_id,
|
|
618
|
+
running=False,
|
|
619
|
+
counters=counters,
|
|
620
|
+
last_error=previous.get("last_error"),
|
|
621
|
+
pid=pid or None,
|
|
622
|
+
extra={
|
|
623
|
+
"stopped_at": now_iso(),
|
|
624
|
+
"stop_timed_out": running,
|
|
625
|
+
"mode": mode,
|
|
626
|
+
"api_url": api_url,
|
|
627
|
+
"host": host,
|
|
628
|
+
"session_id": session_id,
|
|
629
|
+
"launch_token": launch_token,
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
return {
|
|
633
|
+
"ok": not running,
|
|
634
|
+
"merchant_id": merchant_id,
|
|
635
|
+
"pid": pid or None,
|
|
636
|
+
"mode": mode,
|
|
637
|
+
"api_url": api_url,
|
|
638
|
+
"host": host,
|
|
639
|
+
"session_id": session_id,
|
|
640
|
+
"was_running": was_running,
|
|
641
|
+
"running": running,
|
|
642
|
+
"pid_file": str(paths["pid_file"]),
|
|
643
|
+
"state_file": str(paths["state_file"]),
|
|
644
|
+
"stop_file": str(paths["stop_file"]),
|
|
645
|
+
"log_file": str(paths["log_file"]),
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def status_agent(db_path: str | Path, merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Any]:
|
|
650
|
+
paths = agent_paths(merchant_id, state_dir)
|
|
651
|
+
with _PidFileLock(paths["pid_file"]):
|
|
652
|
+
pid_record = read_json(paths["pid_file"], {})
|
|
653
|
+
pid = safe_non_negative_int(pid_record.get("pid"))
|
|
654
|
+
state = read_json(paths["state_file"], {})
|
|
655
|
+
mode = str(pid_record.get("mode") or state.get("mode") or "sqlite")
|
|
656
|
+
host = str(pid_record.get("host") or state.get("host") or "")
|
|
657
|
+
session_id = str(pid_record.get("session_id") or state.get("session_id") or "")
|
|
658
|
+
launch_token = str(pid_record.get("launch_token") or "")
|
|
659
|
+
running = bool(
|
|
660
|
+
launch_token
|
|
661
|
+
and state_matches_pid_record(state, pid_record, merchant_id)
|
|
662
|
+
and pid_record_matches_process(pid_record, merchant_id)
|
|
663
|
+
and state.get("running") is not False
|
|
664
|
+
)
|
|
665
|
+
counters = state.get("counters") or {"checked": 0, "replied": 0}
|
|
666
|
+
return {
|
|
667
|
+
"ok": True,
|
|
668
|
+
"merchant_id": merchant_id,
|
|
669
|
+
"pid": pid or None,
|
|
670
|
+
"mode": mode,
|
|
671
|
+
"api_url": str(pid_record.get("api_url") or state.get("api_url") or ""),
|
|
672
|
+
"host": host,
|
|
673
|
+
"session_id": session_id,
|
|
674
|
+
"running": running,
|
|
675
|
+
"stale_pid": bool(pid and not running),
|
|
676
|
+
"pid_file": str(paths["pid_file"]),
|
|
677
|
+
"state_file": str(paths["state_file"]),
|
|
678
|
+
"stop_file": str(paths["stop_file"]),
|
|
679
|
+
"log_file": str(paths["log_file"]),
|
|
680
|
+
"heartbeat": read_agent_heartbeat(db_path, merchant_id),
|
|
681
|
+
"counters": {
|
|
682
|
+
"checked": safe_non_negative_int(counters.get("checked")),
|
|
683
|
+
"replied": safe_non_negative_int(counters.get("replied")),
|
|
684
|
+
},
|
|
685
|
+
"last_error": state.get("last_error"),
|
|
686
|
+
"started_at": pid_record.get("started_at") or state.get("started_at"),
|
|
687
|
+
"updated_at": state.get("updated_at"),
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def logs_agent(merchant_id: str, tail: int = 20, state_dir: str | Path | None = None) -> dict[str, Any]:
|
|
692
|
+
if tail <= 0:
|
|
693
|
+
raise ValueError("tail must be greater than 0")
|
|
694
|
+
tail = min(tail, MAX_AGENT_LOG_TAIL)
|
|
695
|
+
paths = agent_paths(merchant_id, state_dir)
|
|
696
|
+
entries: list[dict[str, Any]] = []
|
|
697
|
+
raw_lines: list[str] = []
|
|
698
|
+
raw_lines = tail_log_lines(paths["log_file"], tail)
|
|
699
|
+
for line in raw_lines:
|
|
700
|
+
try:
|
|
701
|
+
parsed = json.loads(line)
|
|
702
|
+
except json.JSONDecodeError:
|
|
703
|
+
parsed = {"event": "raw", "text": line}
|
|
704
|
+
if not isinstance(parsed, dict):
|
|
705
|
+
parsed = {"event": "raw", "text": line}
|
|
706
|
+
entries.append(parsed)
|
|
707
|
+
return {"ok": True, "merchant_id": merchant_id, "log_file": str(paths["log_file"]), "entries": entries}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _run_process_loop(
|
|
711
|
+
merchant_id: str,
|
|
712
|
+
process_once: Callable[[], dict[str, Any]],
|
|
713
|
+
mark_away: Callable[[], Any],
|
|
714
|
+
interval: float = 3.0,
|
|
715
|
+
state_file: str | Path | None = None,
|
|
716
|
+
stop_file: str | Path | None = None,
|
|
717
|
+
state_extra: dict[str, Any] | None = None,
|
|
718
|
+
log_file: str | Path | None = None,
|
|
719
|
+
) -> None:
|
|
720
|
+
stop_requested = False
|
|
721
|
+
counters = {"checked": 0, "replied": 0}
|
|
722
|
+
last_error: str | None = None
|
|
723
|
+
state_path = Path(state_file).expanduser() if state_file else None
|
|
724
|
+
stop_path = Path(stop_file).expanduser() if stop_file else None
|
|
725
|
+
log_path = Path(log_file).expanduser() if log_file else None
|
|
726
|
+
interval = safe_positive_float(interval, 3.0, maximum=MAX_AGENT_INTERVAL_SECONDS)
|
|
727
|
+
error_streak = 0
|
|
728
|
+
|
|
729
|
+
def request_stop(_signum: int, _frame: Any) -> None:
|
|
730
|
+
nonlocal stop_requested
|
|
731
|
+
stop_requested = True
|
|
732
|
+
|
|
733
|
+
previous_term = signal.getsignal(signal.SIGTERM)
|
|
734
|
+
previous_int = signal.getsignal(signal.SIGINT)
|
|
735
|
+
signal.signal(signal.SIGTERM, request_stop)
|
|
736
|
+
signal.signal(signal.SIGINT, request_stop)
|
|
737
|
+
try:
|
|
738
|
+
while not stop_requested and not (stop_path and stop_path.exists()):
|
|
739
|
+
# Rotate while running, not only before start; otherwise a
|
|
740
|
+
# long-lived daemon grows its log file without bound.
|
|
741
|
+
if log_path is not None and rotate_agent_log(log_path):
|
|
742
|
+
_redirect_stdout_to(log_path)
|
|
743
|
+
try:
|
|
744
|
+
result = process_once()
|
|
745
|
+
checked = safe_non_negative_int(result.get("checked"))
|
|
746
|
+
replied_count = safe_replied_count(result.get("replied"))
|
|
747
|
+
counters["checked"] += checked
|
|
748
|
+
counters["replied"] += replied_count
|
|
749
|
+
last_error = None
|
|
750
|
+
error_streak = 0
|
|
751
|
+
event = {
|
|
752
|
+
"event": "process_once",
|
|
753
|
+
"at": now_iso(),
|
|
754
|
+
"merchant_id": merchant_id,
|
|
755
|
+
"checked": checked,
|
|
756
|
+
"replied_count": replied_count,
|
|
757
|
+
"counters": counters,
|
|
758
|
+
"result": result,
|
|
759
|
+
}
|
|
760
|
+
except Exception as exc: # pragma: no cover - defensive runtime path
|
|
761
|
+
error_streak += 1
|
|
762
|
+
last_error = f"{type(exc).__name__}: {exc}"
|
|
763
|
+
fatal = error_streak >= 3 and permanent_agent_error(last_error)
|
|
764
|
+
if fatal:
|
|
765
|
+
stop_requested = True
|
|
766
|
+
event = {
|
|
767
|
+
"event": "error",
|
|
768
|
+
"at": now_iso(),
|
|
769
|
+
"merchant_id": merchant_id,
|
|
770
|
+
"counters": counters,
|
|
771
|
+
"error": last_error,
|
|
772
|
+
"error_streak": error_streak,
|
|
773
|
+
"fatal": fatal,
|
|
774
|
+
}
|
|
775
|
+
print(json.dumps(event, ensure_ascii=False, sort_keys=True, default=str), flush=True)
|
|
776
|
+
if state_path:
|
|
777
|
+
write_state(
|
|
778
|
+
state_path,
|
|
779
|
+
merchant_id,
|
|
780
|
+
running=True,
|
|
781
|
+
counters=counters,
|
|
782
|
+
last_error=last_error,
|
|
783
|
+
pid=os.getpid(),
|
|
784
|
+
extra=state_extra,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
retry_interval = min(interval * (2 ** min(error_streak, 6)), MAX_AGENT_ERROR_BACKOFF_SECONDS)
|
|
788
|
+
deadline = time.time() + max(retry_interval, 0.05)
|
|
789
|
+
while not stop_requested and not (stop_path and stop_path.exists()) and time.time() < deadline:
|
|
790
|
+
time.sleep(min(0.1, max(deadline - time.time(), 0.01)))
|
|
791
|
+
finally:
|
|
792
|
+
try:
|
|
793
|
+
mark_away()
|
|
794
|
+
finally:
|
|
795
|
+
if state_path:
|
|
796
|
+
stopped_extra = dict(state_extra or {})
|
|
797
|
+
stopped_extra["stopped_at"] = now_iso()
|
|
798
|
+
write_state(
|
|
799
|
+
state_path,
|
|
800
|
+
merchant_id,
|
|
801
|
+
running=False,
|
|
802
|
+
counters=counters,
|
|
803
|
+
last_error=last_error,
|
|
804
|
+
pid=os.getpid(),
|
|
805
|
+
extra=stopped_extra,
|
|
806
|
+
)
|
|
807
|
+
if stop_path and stop_path.exists():
|
|
808
|
+
stop_path.unlink()
|
|
809
|
+
signal.signal(signal.SIGTERM, previous_term)
|
|
810
|
+
signal.signal(signal.SIGINT, previous_int)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def run_forever(
|
|
814
|
+
db_path: str | Path,
|
|
815
|
+
merchant_id: str,
|
|
816
|
+
interval: float = 3.0,
|
|
817
|
+
state_file: str | Path | None = None,
|
|
818
|
+
stop_file: str | Path | None = None,
|
|
819
|
+
) -> None:
|
|
820
|
+
def process_once() -> dict[str, Any]:
|
|
821
|
+
with db_session(db_path) as conn:
|
|
822
|
+
return merchant_agent.process_once(conn, merchant_id)
|
|
823
|
+
|
|
824
|
+
def mark_away() -> None:
|
|
825
|
+
with db_session(db_path) as conn:
|
|
826
|
+
merchant_agent.heartbeat(conn, merchant_id, status="away")
|
|
827
|
+
|
|
828
|
+
_run_process_loop(
|
|
829
|
+
merchant_id,
|
|
830
|
+
process_once,
|
|
831
|
+
mark_away,
|
|
832
|
+
interval=interval,
|
|
833
|
+
state_file=state_file,
|
|
834
|
+
stop_file=stop_file,
|
|
835
|
+
state_extra={
|
|
836
|
+
"mode": "sqlite",
|
|
837
|
+
"api_url": "",
|
|
838
|
+
"launch_token": str(os.environ.get("SHOPPING_AGENT_LAUNCH_TOKEN") or ""),
|
|
839
|
+
},
|
|
840
|
+
log_file=agent_paths(merchant_id)["log_file"],
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def run_tools_forever(
|
|
845
|
+
tools: Any,
|
|
846
|
+
merchant_id: str,
|
|
847
|
+
interval: float = 3.0,
|
|
848
|
+
state_file: str | Path | None = None,
|
|
849
|
+
stop_file: str | Path | None = None,
|
|
850
|
+
) -> None:
|
|
851
|
+
def process_once() -> dict[str, Any]:
|
|
852
|
+
return merchant_agent.process_once_with_tools(tools, merchant_id)
|
|
853
|
+
|
|
854
|
+
def mark_away() -> None:
|
|
855
|
+
tools.heartbeat(merchant_id, status="away")
|
|
856
|
+
|
|
857
|
+
_run_process_loop(
|
|
858
|
+
merchant_id,
|
|
859
|
+
process_once,
|
|
860
|
+
mark_away,
|
|
861
|
+
interval=interval,
|
|
862
|
+
state_file=state_file,
|
|
863
|
+
stop_file=stop_file,
|
|
864
|
+
state_extra={
|
|
865
|
+
"mode": "api",
|
|
866
|
+
"api_url": str(getattr(tools, "base_url", "") or ""),
|
|
867
|
+
"host": str(getattr(tools, "host", "") or ""),
|
|
868
|
+
"session_id": str(getattr(tools, "session_id", "") or ""),
|
|
869
|
+
"launch_token": str(os.environ.get("SHOPPING_AGENT_LAUNCH_TOKEN") or ""),
|
|
870
|
+
},
|
|
871
|
+
log_file=agent_paths(merchant_id)["log_file"],
|
|
872
|
+
)
|