Add Phase 2 durable workstation agent job routing
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.10.0"
|
||||
__version__ = "1.11.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -71,6 +71,16 @@ class LocalDB:
|
||||
status TEXT NOT NULL DEFAULT 'received',
|
||||
error_message TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS processed_agent_jobs (
|
||||
job_uuid TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
action TEXT NOT NULL,
|
||||
execution_status TEXT NOT NULL DEFAULT 'processing',
|
||||
ok INTEGER NULL,
|
||||
result_json TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
processed_at_utc TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS update_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
@@ -192,6 +202,42 @@ class LocalDB:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_processed_agent_job(self, job_uuid: str, idempotency_key: str) -> dict[str, Any] | None:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM processed_agent_jobs WHERE job_uuid=? OR idempotency_key=? ORDER BY processed_at_utc DESC LIMIT 1",
|
||||
(str(job_uuid or ""), str(idempotency_key or "")),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = dict(row)
|
||||
try:
|
||||
result["result"] = json.loads(result.get("result_json") or "{}")
|
||||
except Exception:
|
||||
result["result"] = {}
|
||||
return result
|
||||
|
||||
def begin_agent_job(self, job_uuid: str, idempotency_key: str, action: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO processed_agent_jobs
|
||||
(job_uuid, idempotency_key, action, execution_status, ok, result_json, error_message, processed_at_utc)
|
||||
VALUES (?, ?, ?, 'processing', NULL, NULL, NULL, ?)""",
|
||||
(str(job_uuid), str(idempotency_key), str(action), _utc_now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def record_processed_agent_job(self, job_uuid: str, idempotency_key: str, action: str, ok: bool, result: Any = None, error_message: str | None = None) -> None:
|
||||
result_json = None if result is None else json.dumps(result, ensure_ascii=False, default=str)
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO processed_agent_jobs
|
||||
(job_uuid, idempotency_key, action, execution_status, ok, result_json, error_message, processed_at_utc)
|
||||
VALUES (?, ?, ?, 'completed', ?, ?, ?, ?)""",
|
||||
(str(job_uuid), str(idempotency_key), str(action), 1 if ok else 0, result_json, error_message, _utc_now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def record_update(self, event_type: str, from_version: str, to_version: str, status: str = "", sha256: str = "", package_path: str = "", error_message: str | None = None, details: Any = None) -> None:
|
||||
details_json = None if details is None else json.dumps(details, ensure_ascii=False, default=str)
|
||||
with self.connect() as conn:
|
||||
|
||||
@@ -71,6 +71,7 @@ class StorageAgentTunnel:
|
||||
jobs = message.get("jobs") or []
|
||||
requests_ = message.get("download_requests") or message.get("requests") or []
|
||||
commands = message.get("commands") or []
|
||||
agent_jobs = message.get("agent_jobs") or []
|
||||
await asyncio.to_thread(self.agent.process_storage_jobs_from_payload, jobs)
|
||||
await asyncio.to_thread(self.agent.process_download_requests_from_payload, requests_)
|
||||
for command in commands:
|
||||
@@ -80,11 +81,14 @@ class StorageAgentTunnel:
|
||||
result = await asyncio.to_thread(self.command_processor.process, command)
|
||||
self.db.command_completed(history_id, bool(result.get("ok")), result.get("error"))
|
||||
await websocket.send(self._json_text(result))
|
||||
for job in agent_jobs:
|
||||
await self._process_agent_job(websocket, job)
|
||||
await websocket.send(self._json_text({
|
||||
"type": "agent_status",
|
||||
"jobs_seen": len(jobs),
|
||||
"requests_seen": len(requests_),
|
||||
"commands_seen": len(commands),
|
||||
"agent_jobs_seen": len(agent_jobs),
|
||||
"capabilities": self._capabilities(),
|
||||
"workstation": self._workstation_status(),
|
||||
"agent_time_utc": self._now(),
|
||||
@@ -93,9 +97,100 @@ class StorageAgentTunnel:
|
||||
if msg_type == "error":
|
||||
self.logger.error("Tunnel server error: %s", message.get("error"))
|
||||
|
||||
async def _process_agent_job(self, websocket, job: dict[str, Any]) -> None:
|
||||
job_uuid = str(job.get("job_uuid") or "").strip()
|
||||
idempotency_key = str(job.get("idempotency_key") or job_uuid).strip()
|
||||
action = str(job.get("action") or "").strip()
|
||||
if not job_uuid or not idempotency_key or not action:
|
||||
return
|
||||
|
||||
cached = self.db.get_processed_agent_job(job_uuid, idempotency_key)
|
||||
if cached:
|
||||
if str(cached.get("execution_status") or "").lower() == "processing":
|
||||
await websocket.send(self._json_text({
|
||||
"type": "agent_job_result",
|
||||
"job_uuid": job_uuid,
|
||||
"idempotency_key": idempotency_key,
|
||||
"ok": False,
|
||||
"result": {},
|
||||
"error": "Previous execution was interrupted before acknowledgement. Verify Tally before retrying this job.",
|
||||
"retryable": False,
|
||||
"indeterminate": True,
|
||||
"replayed": True,
|
||||
"agent_time_utc": self._now(),
|
||||
"workstation": self._workstation_status(),
|
||||
}))
|
||||
return
|
||||
await websocket.send(self._json_text({
|
||||
"type": "agent_job_result",
|
||||
"job_uuid": job_uuid,
|
||||
"idempotency_key": idempotency_key,
|
||||
"ok": bool(cached.get("ok")),
|
||||
"result": cached.get("result") or {},
|
||||
"error": cached.get("error_message"),
|
||||
"retryable": False,
|
||||
"replayed": True,
|
||||
"agent_time_utc": self._now(),
|
||||
"workstation": self._workstation_status(),
|
||||
}))
|
||||
return
|
||||
|
||||
# Persist 'processing' before touching Tally. If Windows/Tally/agent dies in
|
||||
# the narrow post-before-ack window, the next connection refuses to blindly
|
||||
# execute the same accounting job again.
|
||||
self.db.begin_agent_job(job_uuid, idempotency_key, action)
|
||||
history_id = self.db.command_received(job_uuid, action)
|
||||
command = {
|
||||
"command_id": job_uuid,
|
||||
"action": action,
|
||||
"payload": job.get("payload") or {},
|
||||
}
|
||||
try:
|
||||
result = await asyncio.to_thread(self.command_processor.process, command)
|
||||
ok = bool(result.get("ok"))
|
||||
error = result.get("error")
|
||||
result_payload = result.get("result") or {}
|
||||
self.db.command_completed(history_id, ok, error)
|
||||
# Both success and deterministic application errors are cached so a
|
||||
# reconnect cannot execute the same accounting write twice.
|
||||
self.db.record_processed_agent_job(
|
||||
job_uuid, idempotency_key, action, ok, result_payload, error
|
||||
)
|
||||
await websocket.send(self._json_text({
|
||||
"type": "agent_job_result",
|
||||
"job_uuid": job_uuid,
|
||||
"idempotency_key": idempotency_key,
|
||||
"ok": ok,
|
||||
"result": result_payload,
|
||||
"error": error,
|
||||
"retryable": False,
|
||||
"replayed": False,
|
||||
"agent_time_utc": self._now(),
|
||||
"workstation": self._workstation_status(),
|
||||
}))
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
self.db.command_completed(history_id, False, error)
|
||||
# Execution has already crossed the durable local 'processing' marker.
|
||||
# We cannot prove that Tally did not accept a write before this exception,
|
||||
# therefore this is deliberately non-retryable and requires verification.
|
||||
await websocket.send(self._json_text({
|
||||
"type": "agent_job_result",
|
||||
"job_uuid": job_uuid,
|
||||
"idempotency_key": idempotency_key,
|
||||
"ok": False,
|
||||
"result": {},
|
||||
"error": f"Agent execution became indeterminate: {error}",
|
||||
"retryable": False,
|
||||
"indeterminate": True,
|
||||
"replayed": False,
|
||||
"agent_time_utc": self._now(),
|
||||
"workstation": self._workstation_status(),
|
||||
}))
|
||||
|
||||
def _capabilities(self) -> list[str]:
|
||||
return [
|
||||
"storage", "local_dashboard", "manual_updates", "accounting_act",
|
||||
"storage", "local_dashboard", "manual_updates", "accounting_act", "agent.jobs.v2",
|
||||
"tally.status", "tally.company_identity", "tally.read_groups",
|
||||
"tally.read_ledgers", "tally.read_stock_items", "tally.read_vouchers",
|
||||
"tally.read_trial_balance", "tally.master_sync", "tally.transaction_sync",
|
||||
|
||||
Reference in New Issue
Block a user