Add Phase 2 durable workstation agent job routing

This commit is contained in:
A R R R Associates
2026-08-22 11:50:20 +05:30
parent c8b7dcb805
commit f7bf1437ef
9 changed files with 657 additions and 10 deletions
@@ -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",