230 lines
7.1 KiB
Python
230 lines
7.1 KiB
Python
"""Persist background bash task records.
|
|
|
|
Sibling to run_state_store.py: a SQLite table tracking bg shell tasks the
|
|
agent spawns via bash(run_in_background=true). The store survives backend
|
|
restarts so polling can be resumed; the actual remote process is owned by
|
|
nohup on the Jupyter terminal and is independent of this store.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from threading import RLock
|
|
from typing import Any
|
|
|
|
|
|
ACTIVE_BG_STATUSES = {'running'}
|
|
TERMINAL_BG_STATUSES = {'completed', 'failed', 'cancelled'}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BgTaskSpec:
|
|
"""Payload an agent-side handler hands to the backend on registration."""
|
|
|
|
task_id: str
|
|
account_key: str
|
|
session_id: str
|
|
run_id: str
|
|
pid: int
|
|
task_dir: str
|
|
output_path: str
|
|
pid_path: str
|
|
exit_code_path: str
|
|
command: str
|
|
started_at: float
|
|
wait_for_completion: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BgTaskStatus:
|
|
"""Snapshot the agent reads back via bash_status."""
|
|
|
|
task_id: str
|
|
status: str
|
|
pid: int
|
|
started_at: float
|
|
finished_at: float | None
|
|
exit_code: int | None
|
|
output_path: str
|
|
output_preview: str
|
|
auto_resumed: bool
|
|
|
|
|
|
class BashBgStore:
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.db_path = db_path
|
|
self._lock = RLock()
|
|
|
|
def record_start(self, spec: BgTaskSpec) -> None:
|
|
now = time.time()
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
insert into bash_bg_tasks (
|
|
task_id, account_key, session_id, run_id, pid,
|
|
task_dir, output_path, pid_path, exit_code_path,
|
|
command, status, started_at, updated_at,
|
|
wait_for_completion, auto_resumed
|
|
)
|
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, 0)
|
|
on conflict(task_id) do nothing
|
|
""",
|
|
(
|
|
spec.task_id,
|
|
spec.account_key,
|
|
spec.session_id,
|
|
spec.run_id,
|
|
spec.pid,
|
|
spec.task_dir,
|
|
spec.output_path,
|
|
spec.pid_path,
|
|
spec.exit_code_path,
|
|
spec.command,
|
|
spec.started_at,
|
|
now,
|
|
1 if spec.wait_for_completion else 0,
|
|
),
|
|
)
|
|
|
|
def mark_completed(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
exit_code: int,
|
|
finished_at: float | None = None,
|
|
) -> None:
|
|
finished = finished_at if finished_at is not None else time.time()
|
|
status = 'completed' if exit_code == 0 else 'failed'
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
update bash_bg_tasks
|
|
set status = ?, exit_code = ?, finished_at = ?, updated_at = ?
|
|
where task_id = ? and status = 'running'
|
|
""",
|
|
(status, exit_code, finished, finished, task_id),
|
|
)
|
|
|
|
def mark_killed(self, task_id: str) -> None:
|
|
now = time.time()
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
update bash_bg_tasks
|
|
set status = 'cancelled', finished_at = ?, updated_at = ?
|
|
where task_id = ? and status = 'running'
|
|
""",
|
|
(now, now, task_id),
|
|
)
|
|
|
|
def mark_auto_resumed(self, task_id: str) -> bool:
|
|
"""Set auto_resumed=1 atomically; return True if we won the race."""
|
|
with self._connect() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
update bash_bg_tasks
|
|
set auto_resumed = 1, updated_at = ?
|
|
where task_id = ? and auto_resumed = 0
|
|
""",
|
|
(time.time(), task_id),
|
|
)
|
|
return cursor.rowcount > 0
|
|
|
|
def list_active(self) -> list[dict[str, Any]]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"select * from bash_bg_tasks where status = 'running'"
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def list_for_session(
|
|
self,
|
|
account_key: str,
|
|
session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
select * from bash_bg_tasks
|
|
where account_key = ? and session_id = ?
|
|
order by started_at desc
|
|
""",
|
|
(account_key, session_id),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def list_active_for_session(
|
|
self,
|
|
account_key: str,
|
|
session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
select * from bash_bg_tasks
|
|
where account_key = ?
|
|
and session_id = ?
|
|
and status = 'running'
|
|
order by started_at desc
|
|
""",
|
|
(account_key, session_id),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get(self, task_id: str) -> dict[str, Any] | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"select * from bash_bg_tasks where task_id = ?",
|
|
(task_id,),
|
|
).fetchone()
|
|
return dict(row) if row is not None else None
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
with self._lock:
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(self.db_path, timeout=10)
|
|
conn.row_factory = sqlite3.Row
|
|
self._init_schema(conn)
|
|
return conn
|
|
|
|
def _init_schema(self, conn: sqlite3.Connection) -> None:
|
|
conn.execute('pragma journal_mode=wal')
|
|
conn.execute(
|
|
"""
|
|
create table if not exists bash_bg_tasks (
|
|
task_id text primary key,
|
|
account_key text not null,
|
|
session_id text not null,
|
|
run_id text not null default '',
|
|
pid integer not null,
|
|
task_dir text not null,
|
|
output_path text not null,
|
|
pid_path text not null,
|
|
exit_code_path text not null,
|
|
command text not null,
|
|
status text not null,
|
|
started_at real not null,
|
|
finished_at real,
|
|
exit_code integer,
|
|
updated_at real not null,
|
|
wait_for_completion integer not null default 1,
|
|
auto_resumed integer not null default 0
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
create index if not exists idx_bash_bg_session
|
|
on bash_bg_tasks(account_key, session_id, started_at)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
create index if not exists idx_bash_bg_active
|
|
on bash_bg_tasks(status)
|
|
"""
|
|
)
|