mirror of
https://github.com/langgenius/dify.git
synced 2026-07-14 08:57:02 +08:00
1233 lines
47 KiB
Python
1233 lines
47 KiB
Python
"""SQLite-backed shellctl job service.
|
|
|
|
This module owns the long-lived job lifecycle rules: artifact creation,
|
|
SQLite compare-and-swap transitions, tmux reconciliation, runner exit
|
|
materialization, and GC. API and local server entrypoints should call into
|
|
`ShellctlService` rather than duplicating lifecycle logic, choosing either the
|
|
lightweight `prepare_runtime()` bootstrap or the full `initialize()` startup
|
|
depending on whether they need bounded setup work or long-running maintenance.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import anyio
|
|
from sqlalchemy import case, delete, select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
from sqlmodel import SQLModel
|
|
|
|
from shellctl.server.artifacts import (
|
|
JOB_ENV_FILENAME,
|
|
RUNNER_ENDED_AT_FILENAME,
|
|
RUNNER_EXIT_CODE_FILENAME,
|
|
job_env_path,
|
|
pipe_drained_path,
|
|
pipe_error_log_path,
|
|
pipe_failed_path,
|
|
runner_ended_at_path,
|
|
runner_exit_code_path,
|
|
)
|
|
from shellctl.server.config import ShellctlConfig
|
|
from shellctl.server.db import JobRow, configure_sqlite_engine
|
|
from shellctl.server.errors import ShellctlServerError
|
|
from shellctl.server.tmux import (
|
|
TmuxController,
|
|
TmuxControllerProtocol,
|
|
)
|
|
from shellctl.shared.constants import DEFAULT_AUTH_TOKEN_ENV
|
|
from shellctl.shared.output import (
|
|
OutputWindow,
|
|
read_output_window,
|
|
tail_output_window,
|
|
)
|
|
from shellctl.shared.runtime import (
|
|
format_timestamp,
|
|
generate_job_id,
|
|
is_terminal_status,
|
|
job_pane_target,
|
|
job_session_name,
|
|
parse_timestamp,
|
|
utc_now,
|
|
)
|
|
from shellctl.shared.schemas import (
|
|
DeleteJobResponse,
|
|
InputJobRequest,
|
|
JobInfo,
|
|
JobResult,
|
|
JobStatusName,
|
|
JobStatusView,
|
|
ListJobsResponse,
|
|
RunJobRequest,
|
|
TerminalSize,
|
|
TerminateJobRequest,
|
|
WaitJobRequest,
|
|
)
|
|
|
|
|
|
class ShellctlService:
|
|
"""SQLite-backed shellctl job service used by HTTP and local server entrypoints.
|
|
|
|
The service keeps only minimal in-memory coordination state:
|
|
|
|
- `_starting_jobs` prevents `created`/`starting` rows from being marked
|
|
`lost` while the current startup path is still installing tmux state.
|
|
|
|
Everything else uses SQLite conditional updates so concurrent readers and
|
|
lifecycle paths converge through the DB rather than through mutable JSON
|
|
artifacts.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: ShellctlConfig,
|
|
*,
|
|
tmux: TmuxControllerProtocol | None = None,
|
|
) -> None:
|
|
self.config = config
|
|
self._tmux = tmux or TmuxController(config)
|
|
self._gc_task: asyncio.Task[None] | None = None
|
|
self._monitor_task: asyncio.Task[None] | None = None
|
|
self._starting_jobs: set[str] = set()
|
|
self._engine = create_async_engine(self.config.database_url, future=True)
|
|
configure_sqlite_engine(
|
|
self._engine,
|
|
busy_timeout_ms=self.config.sqlite_busy_timeout_ms,
|
|
)
|
|
self._session_factory = async_sessionmaker(
|
|
self._engine,
|
|
expire_on_commit=False,
|
|
class_=AsyncSession,
|
|
)
|
|
|
|
async def initialize_database(self) -> None:
|
|
"""Ensure the SQLite database exists and its schema is initialized."""
|
|
|
|
self._ensure_dir(self.config.state_dir)
|
|
async with self._engine.begin() as connection:
|
|
await connection.run_sync(SQLModel.metadata.create_all)
|
|
|
|
async def prepare_runtime(self) -> None:
|
|
"""Prepare the minimal local runtime needed for bounded server-side work.
|
|
|
|
This startup path intentionally skips reconciliation, GC, and background
|
|
tasks. Entry points that only need local runtime artifacts should stay
|
|
bounded by the requested operation instead of scanning or maintaining
|
|
the full historical job set on every invocation.
|
|
"""
|
|
|
|
await self.initialize_database()
|
|
self._ensure_dir(cast(Path, self.config.runtime_dir))
|
|
self._ensure_dir(self.config.jobs_dir)
|
|
self._ensure_dir(self.config.runner_path.parent)
|
|
self._install_runner()
|
|
await self._tmux.start_server()
|
|
|
|
async def initialize(self) -> None:
|
|
"""Run the full server startup path used by `shellctl serve`.
|
|
|
|
Auth is configured at the API layer, so service startup must also work
|
|
when `shellctl serve` is intentionally running without a bearer token.
|
|
Unlike `prepare_runtime()`, this path also reconciles persisted jobs and
|
|
performs one foreground GC pass before the long-running server starts.
|
|
"""
|
|
|
|
await self.prepare_runtime()
|
|
await self.reconcile()
|
|
await self.gc_once()
|
|
|
|
async def shutdown(self) -> None:
|
|
"""Stop background work and close the SQLite engine."""
|
|
|
|
for task_name in ("_gc_task", "_monitor_task"):
|
|
task = getattr(self, task_name)
|
|
if task is not None:
|
|
task.cancel()
|
|
with anyio.CancelScope(shield=True):
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
setattr(self, task_name, None)
|
|
await self._engine.dispose()
|
|
|
|
def start_background_gc(self) -> None:
|
|
if self._gc_task is None:
|
|
self._gc_task = asyncio.create_task(self._gc_loop(), name="shellctl-gc")
|
|
|
|
def start_background_pipe_monitor(self) -> None:
|
|
if self._monitor_task is None:
|
|
self._monitor_task = asyncio.create_task(
|
|
self._pipe_monitor_loop(), name="shellctl-pipe-monitor"
|
|
)
|
|
|
|
async def run_job(self, request: RunJobRequest) -> JobResult:
|
|
"""Create a tmux-backed job and wait for its initial result window.
|
|
|
|
Side effects:
|
|
- allocates `jobs/<job_id>/` and writes `script`, `.job-env.json`, and
|
|
`output.log`
|
|
- inserts a `jobs` row into SQLite, then conditionally transitions it
|
|
through `created -> starting -> running`
|
|
- creates a dedicated tmux session, installs `pipe-pane`, waits for the
|
|
sanitize/output pipeline ready-file handshake, and only then opens the
|
|
`start-gate`
|
|
- on startup failure, marks the job `failed` in SQLite and cleans up the
|
|
tmux session without deleting the job artifacts
|
|
|
|
Returns:
|
|
The same `JobResult` shape as `wait()`, starting from byte offset 0.
|
|
|
|
Notes:
|
|
The startup reservation in `_starting_jobs` exists so concurrent
|
|
status/list/reconcile calls can observe a fresh row as a legitimate
|
|
`created`/`starting` job instead of materializing it to `lost` before
|
|
tmux becomes visible.
|
|
"""
|
|
|
|
cwd = self._resolve_cwd(request.cwd)
|
|
env = self._resolve_env(request.env)
|
|
terminal = request.terminal or TerminalSize(
|
|
cols=self.config.default_terminal_cols,
|
|
rows=self.config.default_terminal_rows,
|
|
)
|
|
created_at = format_timestamp()
|
|
|
|
job_id: str | None = None
|
|
job_dir: Path | None = None
|
|
while True:
|
|
candidate_job_id, candidate_job_dir = self._allocate_job_dir()
|
|
try:
|
|
self._starting_jobs.add(candidate_job_id)
|
|
script_path = candidate_job_dir / "script"
|
|
output_path = candidate_job_dir / "output.log"
|
|
script_path.write_text(request.script, encoding="utf-8")
|
|
job_env_path(candidate_job_dir).write_text(
|
|
json.dumps(env, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
output_path.touch()
|
|
row = JobRow(
|
|
job_id=candidate_job_id,
|
|
script_path=f"jobs/{candidate_job_id}/script",
|
|
output_path=f"jobs/{candidate_job_id}/output.log",
|
|
cwd=str(cwd),
|
|
terminal_cols=terminal.cols,
|
|
terminal_rows=terminal.rows,
|
|
status=JobStatusName.CREATED.value,
|
|
session_name=job_session_name(candidate_job_id),
|
|
pane_target=job_pane_target(candidate_job_id),
|
|
exit_code=None,
|
|
reason=None,
|
|
message=None,
|
|
created_at=created_at,
|
|
started_at=None,
|
|
ended_at=None,
|
|
updated_at=created_at,
|
|
)
|
|
if await self._insert_job_row(row):
|
|
job_id = candidate_job_id
|
|
job_dir = candidate_job_dir
|
|
break
|
|
self._starting_jobs.discard(candidate_job_id)
|
|
except BaseException:
|
|
self._starting_jobs.discard(candidate_job_id)
|
|
shutil.rmtree(candidate_job_dir, ignore_errors=True)
|
|
raise
|
|
shutil.rmtree(candidate_job_dir, ignore_errors=True)
|
|
|
|
assert job_id is not None and job_dir is not None
|
|
await self._transition_status(
|
|
job_id,
|
|
allowed_from={JobStatusName.CREATED},
|
|
target=JobStatusName.STARTING,
|
|
)
|
|
|
|
try:
|
|
pipe_ready_path = job_dir / ".pipe-ready"
|
|
await self._tmux.create_job_session(
|
|
job_id=job_id,
|
|
job_dir=job_dir,
|
|
cwd=cwd,
|
|
terminal=terminal,
|
|
)
|
|
await self._tmux.enable_output_pipe(
|
|
job_id=job_id,
|
|
job_dir=job_dir,
|
|
ready_file=pipe_ready_path,
|
|
)
|
|
await self._wait_for_output_pipe_ready(
|
|
job_id=job_id,
|
|
ready_file=pipe_ready_path,
|
|
)
|
|
(job_dir / "start-gate").touch()
|
|
await self._transition_status(
|
|
job_id,
|
|
allowed_from={JobStatusName.STARTING},
|
|
target=JobStatusName.RUNNING,
|
|
require_exit_code_null=True,
|
|
)
|
|
except Exception as exc:
|
|
await self._transition_status(
|
|
job_id,
|
|
allowed_from={
|
|
JobStatusName.CREATED,
|
|
JobStatusName.STARTING,
|
|
JobStatusName.RUNNING,
|
|
},
|
|
target=JobStatusName.FAILED,
|
|
reason=getattr(exc, "code", "start_failed"),
|
|
message=str(exc),
|
|
)
|
|
await self._tmux.cleanup_session(job_id=job_id)
|
|
finally:
|
|
(job_dir / ".pipe-ready").unlink(missing_ok=True)
|
|
self._starting_jobs.discard(job_id)
|
|
|
|
return await self.wait_job(
|
|
job_id,
|
|
WaitJobRequest(
|
|
offset=0,
|
|
timeout=request.timeout,
|
|
output_limit=request.output_limit,
|
|
idle_flush_seconds=request.idle_flush_seconds,
|
|
),
|
|
)
|
|
|
|
async def wait_job(self, job_id: str, request: WaitJobRequest) -> JobResult:
|
|
"""Block until output, completion, truncation, or timeout."""
|
|
|
|
row = await self._get_job_row(job_id)
|
|
output_path = self._output_log_path(row)
|
|
self._validate_offset(output_path, request.offset)
|
|
deadline = anyio.current_time() + request.timeout
|
|
last_size = output_path.stat().st_size if output_path.exists() else 0
|
|
saw_output = last_size > request.offset
|
|
last_growth_at: float | None = anyio.current_time() if saw_output else None
|
|
|
|
while True:
|
|
view = await self.get_job_status(job_id)
|
|
current_size = output_path.stat().st_size if output_path.exists() else 0
|
|
if request.offset > current_size:
|
|
raise ShellctlServerError(
|
|
400,
|
|
"invalid_offset",
|
|
f"offset {request.offset} exceeds current file size {current_size}",
|
|
)
|
|
|
|
if current_size > last_size:
|
|
last_size = current_size
|
|
if current_size > request.offset:
|
|
saw_output = True
|
|
last_growth_at = anyio.current_time()
|
|
|
|
if view.done:
|
|
window = read_output_window(
|
|
output_path,
|
|
offset=request.offset,
|
|
limit=request.output_limit,
|
|
)
|
|
return self._job_result_from_view(view, row, window)
|
|
|
|
if current_size > request.offset:
|
|
window = read_output_window(
|
|
output_path,
|
|
offset=request.offset,
|
|
limit=request.output_limit,
|
|
)
|
|
if window.truncated:
|
|
return self._job_result_from_view(view, row, window)
|
|
if saw_output and last_growth_at is not None:
|
|
idle_elapsed = anyio.current_time() - last_growth_at
|
|
if idle_elapsed >= request.idle_flush_seconds:
|
|
return self._job_result_from_view(view, row, window)
|
|
|
|
if anyio.current_time() >= deadline:
|
|
window = (
|
|
read_output_window(
|
|
output_path,
|
|
offset=request.offset,
|
|
limit=request.output_limit,
|
|
)
|
|
if current_size > request.offset
|
|
else OutputWindow(output="", offset=request.offset, truncated=False)
|
|
)
|
|
return self._job_result_from_view(view, row, window)
|
|
|
|
await anyio.sleep(self.config.poll_interval_seconds)
|
|
|
|
async def tail_job(self, job_id: str, *, output_limit: int) -> JobResult:
|
|
row = await self._get_job_row(job_id)
|
|
view = await self.get_job_status(job_id)
|
|
tail = tail_output_window(self._output_log_path(row), limit=output_limit)
|
|
return self._job_result_from_view(view, row, tail, truncated=False)
|
|
|
|
async def get_job_status(self, job_id: str) -> JobStatusView:
|
|
session_exists, pipe_active = await self._live_runtime_state(job_id)
|
|
view = await self._materialize_status_view(
|
|
job_id,
|
|
session_exists=session_exists,
|
|
pipe_active=pipe_active,
|
|
)
|
|
if view.done:
|
|
await self._tmux.cleanup_session(job_id=job_id)
|
|
return view
|
|
|
|
async def list_jobs(
|
|
self,
|
|
*,
|
|
status: JobStatusName | None = None,
|
|
limit: int,
|
|
) -> ListJobsResponse:
|
|
"""List recent jobs ordered by `created_at` descending.
|
|
|
|
This intentionally uses live per-job runtime queries instead of a single
|
|
tmux snapshot so concurrent startup cannot turn a healthy job into `lost`.
|
|
Rows may also disappear concurrently because delete removes the DB record
|
|
before artifact cleanup; such races are treated as benign skips rather
|
|
than failing the whole list request.
|
|
"""
|
|
|
|
rows = await self._list_job_rows()
|
|
items: list[JobInfo] = []
|
|
for row in rows:
|
|
try:
|
|
view = await self.get_job_status(row.job_id)
|
|
except ShellctlServerError as exc:
|
|
if exc.code == "job_not_found":
|
|
continue
|
|
raise
|
|
if status is not None and view.status != status:
|
|
continue
|
|
items.append(
|
|
JobInfo(
|
|
job_id=view.job_id,
|
|
status=view.status,
|
|
created_at=view.created_at,
|
|
started_at=view.started_at,
|
|
ended_at=view.ended_at,
|
|
)
|
|
)
|
|
if len(items) >= limit:
|
|
break
|
|
return ListJobsResponse(jobs=items)
|
|
|
|
async def send_input(self, job_id: str, request: InputJobRequest) -> JobResult:
|
|
"""Send input to a running job and then wait using the same semantics as `wait()`.
|
|
|
|
If the pane disappears after the initial running-state check, this method
|
|
re-materializes the job state before deciding whether to return terminal
|
|
`409` semantics or a true internal tmux failure.
|
|
"""
|
|
|
|
view = await self.get_job_status(job_id)
|
|
if view.done:
|
|
raise ShellctlServerError(
|
|
409, "job_not_running", f"Job {job_id} is already terminal"
|
|
)
|
|
try:
|
|
await self._tmux.send_input(job_id=job_id, text=request.text)
|
|
except ShellctlServerError as exc:
|
|
if exc.code != "tmux_target_missing":
|
|
raise
|
|
view = await self.get_job_status(job_id)
|
|
if view.done:
|
|
raise ShellctlServerError(
|
|
409, "job_not_running", f"Job {job_id} is already terminal"
|
|
) from exc
|
|
raise ShellctlServerError(500, "tmux_input_failed", exc.message) from exc
|
|
return await self.wait_job(
|
|
job_id,
|
|
WaitJobRequest(
|
|
offset=request.offset,
|
|
timeout=request.timeout,
|
|
output_limit=request.output_limit,
|
|
idle_flush_seconds=request.idle_flush_seconds,
|
|
),
|
|
)
|
|
|
|
async def terminate_job(
|
|
self, job_id: str, request: TerminateJobRequest
|
|
) -> JobStatusView:
|
|
"""Terminate a job, reserving `terminated` before tmux cleanup.
|
|
|
|
The initial conditional DB update allows an `exited` row to be overridden
|
|
only when it raced after an earlier non-terminal read in this method. That
|
|
preserves proposal semantics: once a user terminates a non-terminal job,
|
|
the final API status stays `terminated` even if the runner exits during the
|
|
same window.
|
|
"""
|
|
|
|
view = await self.get_job_status(job_id)
|
|
if view.done:
|
|
await self._tmux.cleanup_session(job_id=job_id)
|
|
return view
|
|
|
|
await self._transition_status(
|
|
job_id,
|
|
allowed_from={
|
|
JobStatusName.CREATED,
|
|
JobStatusName.STARTING,
|
|
JobStatusName.RUNNING,
|
|
},
|
|
allow_override_from={JobStatusName.EXITED},
|
|
target=JobStatusName.TERMINATED,
|
|
)
|
|
await self._tmux.send_interrupt(job_id=job_id)
|
|
if request.grace_seconds > 0:
|
|
await anyio.sleep(request.grace_seconds)
|
|
await self._tmux.cleanup_session(job_id=job_id)
|
|
return await self.get_job_status(job_id)
|
|
|
|
async def delete_job(
|
|
self,
|
|
job_id: str,
|
|
*,
|
|
force: bool,
|
|
grace_seconds: float,
|
|
) -> DeleteJobResponse:
|
|
"""Delete a job row and its artifacts.
|
|
|
|
Behavior:
|
|
- if the job is still non-terminal and `force` is false, raises `409`
|
|
- if `force` is true, first applies normal terminate semantics
|
|
- always best-effort cleans the tmux session before removing persisted
|
|
state
|
|
|
|
Deletion ordering matters: the SQLite row is removed before best-effort
|
|
artifact-directory cleanup. That ordering intentionally avoids exposing a
|
|
visible half-deleted state where API listing/status can still see a row
|
|
whose `script` / `output.log` artifacts are already gone.
|
|
"""
|
|
|
|
view = await self.get_job_status(job_id)
|
|
if not view.done:
|
|
if not force:
|
|
raise ShellctlServerError(
|
|
409, "job_running", f"Job {job_id} is still running"
|
|
)
|
|
await self.terminate_job(
|
|
job_id, TerminateJobRequest(grace_seconds=grace_seconds)
|
|
)
|
|
await self._tmux.cleanup_session(job_id=job_id)
|
|
await self._delete_job_row(job_id)
|
|
shutil.rmtree(self._artifact_dir(job_id), ignore_errors=True)
|
|
return DeleteJobResponse(job_id=job_id)
|
|
|
|
async def reconcile(self) -> None:
|
|
"""Reconcile SQLite rows against live tmux state using per-job probes.
|
|
|
|
Reconciliation also uses a live per-job runtime query to avoid turning a
|
|
job that is still completing startup into `lost` based on an earlier tmux
|
|
snapshot.
|
|
Jobs may disappear between the initial row query and the later status
|
|
materialization because delete removes the SQLite row first. Reconciliation
|
|
treats that race as benign and continues with the remaining jobs.
|
|
"""
|
|
|
|
for row in await self._list_job_rows():
|
|
try:
|
|
view = await self.get_job_status(row.job_id)
|
|
except ShellctlServerError as exc:
|
|
if exc.code == "job_not_found":
|
|
continue
|
|
raise
|
|
if view.done:
|
|
await self._tmux.cleanup_session(job_id=row.job_id)
|
|
|
|
async def gc_once(self) -> None:
|
|
"""Delete expired terminal rows and their artifact directories.
|
|
|
|
Like reconciliation, GC tolerates rows disappearing mid-pass due to a
|
|
concurrent explicit delete.
|
|
|
|
Deletion ordering matches explicit delete semantics: remove the SQLite
|
|
row first, then best-effort delete the artifact directory. That keeps the
|
|
DB/API source of truth from exposing a row whose filesystem artifacts have
|
|
already been removed.
|
|
"""
|
|
|
|
cutoff = utc_now().timestamp() - self.config.gc_finished_job_retention_seconds
|
|
for row in await self._list_job_rows():
|
|
try:
|
|
view = await self.get_job_status(row.job_id)
|
|
except ShellctlServerError as exc:
|
|
if exc.code == "job_not_found":
|
|
continue
|
|
raise
|
|
if not view.done or view.ended_at is None:
|
|
continue
|
|
if parse_timestamp(view.ended_at).timestamp() >= cutoff:
|
|
continue
|
|
await self._tmux.cleanup_session(job_id=row.job_id)
|
|
try:
|
|
await self._delete_job_row(row.job_id)
|
|
except ShellctlServerError as exc:
|
|
if exc.code == "job_not_found":
|
|
continue
|
|
raise
|
|
shutil.rmtree(self._artifact_dir(row.job_id), ignore_errors=True)
|
|
|
|
async def record_runner_exit(
|
|
self, job_id: str, exit_code: int, ended_at: str
|
|
) -> None:
|
|
"""Persist a drained normal-exit fact into SQLite.
|
|
|
|
The usual caller is the tmux pipe finalizer after the lightweight PTY
|
|
sanitizer reaches EOF and flushes `output.log`. `_materialize_status_view()`
|
|
may also call this as a recovery path when `.pipe-drained` plus the
|
|
normal exit metadata files exist but SQLite is still non-terminal. The
|
|
statement always records `exit_code` and `ended_at`, but it only changes
|
|
`status` to `exited` when the current row is still non-terminal.
|
|
"""
|
|
|
|
nonterminal = [
|
|
JobStatusName.CREATED.value,
|
|
JobStatusName.STARTING.value,
|
|
JobStatusName.RUNNING.value,
|
|
]
|
|
job_id_col = cast(Any, JobRow.job_id)
|
|
status_col = cast(Any, JobRow.status)
|
|
ended_at_col = cast(Any, JobRow.ended_at)
|
|
reason_col = cast(Any, JobRow.reason)
|
|
message_col = cast(Any, JobRow.message)
|
|
async with self._session_factory() as session:
|
|
stmt = (
|
|
update(JobRow)
|
|
.where(job_id_col == job_id)
|
|
.values(
|
|
exit_code=exit_code,
|
|
ended_at=case(
|
|
(ended_at_col.is_(None), ended_at),
|
|
else_=ended_at_col,
|
|
),
|
|
updated_at=ended_at,
|
|
status=case(
|
|
(status_col.in_(nonterminal), JobStatusName.EXITED.value),
|
|
else_=status_col,
|
|
),
|
|
reason=case(
|
|
(status_col.in_(nonterminal), None),
|
|
else_=reason_col,
|
|
),
|
|
message=case(
|
|
(status_col.in_(nonterminal), None),
|
|
else_=message_col,
|
|
),
|
|
)
|
|
)
|
|
result = await session.execute(stmt)
|
|
if cast(Any, result).rowcount == 0:
|
|
raise ShellctlServerError(
|
|
404, "job_not_found", f"Unknown job id: {job_id}"
|
|
)
|
|
await session.commit()
|
|
|
|
async def check_running_jobs_pipe_health(self) -> None:
|
|
"""Fail running jobs whose tmux output pipe disappeared after startup."""
|
|
|
|
for row in await self._list_job_rows(statuses={JobStatusName.RUNNING}):
|
|
session_exists, pipe_active = await self._live_runtime_state(row.job_id)
|
|
view = await self._materialize_status_view(
|
|
row.job_id,
|
|
session_exists=session_exists,
|
|
pipe_active=pipe_active,
|
|
)
|
|
if view.done:
|
|
await self._tmux.cleanup_session(job_id=row.job_id)
|
|
|
|
async def _gc_loop(self) -> None:
|
|
while True:
|
|
await anyio.sleep(self.config.gc_interval_seconds)
|
|
await self.gc_once()
|
|
|
|
async def _pipe_monitor_loop(self) -> None:
|
|
while True:
|
|
await anyio.sleep(self.config.pipe_monitor_interval_seconds)
|
|
await self.check_running_jobs_pipe_health()
|
|
|
|
async def _wait_for_output_pipe_ready(
|
|
self, *, job_id: str, ready_file: Path
|
|
) -> None:
|
|
"""Confirm the sanitize/output pipeline is live before opening start-gate.
|
|
|
|
Timeout failures include the ready-file path, current `#{pane_pipe}`
|
|
state, and a summary of `pipe-error.log` so operators can tell the
|
|
difference between slow startup, tmux pipe loss, and sanitizer crashes.
|
|
"""
|
|
|
|
started_at = anyio.current_time()
|
|
deadline = started_at + self.config.pipe_ready_timeout_seconds
|
|
while True:
|
|
waited_seconds = anyio.current_time() - started_at
|
|
if ready_file.exists():
|
|
pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
if pipe_state is True:
|
|
return
|
|
if pipe_state is None:
|
|
raise ShellctlServerError(
|
|
500,
|
|
"pipe_failed",
|
|
self._pipe_ready_failure_message(
|
|
job_id=job_id,
|
|
ready_file=ready_file,
|
|
waited_seconds=waited_seconds,
|
|
pipe_state=pipe_state,
|
|
cause="tmux pane disappeared after the ready-file handshake",
|
|
),
|
|
)
|
|
else:
|
|
if not await self._tmux.session_exists(job_session_name(job_id)):
|
|
pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
raise ShellctlServerError(
|
|
500,
|
|
"pipe_failed",
|
|
self._pipe_ready_failure_message(
|
|
job_id=job_id,
|
|
ready_file=ready_file,
|
|
waited_seconds=waited_seconds,
|
|
pipe_state=pipe_state,
|
|
cause="tmux session exited before the ready-file handshake",
|
|
),
|
|
)
|
|
if anyio.current_time() >= deadline:
|
|
pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
raise ShellctlServerError(
|
|
500,
|
|
"pipe_failed",
|
|
self._pipe_ready_failure_message(
|
|
job_id=job_id,
|
|
ready_file=ready_file,
|
|
waited_seconds=waited_seconds,
|
|
pipe_state=pipe_state,
|
|
cause="timed out waiting for the sanitize/output pipeline handshake",
|
|
),
|
|
)
|
|
await anyio.sleep(self.config.poll_interval_seconds)
|
|
|
|
def _pipe_ready_failure_message(
|
|
self,
|
|
*,
|
|
job_id: str,
|
|
ready_file: Path,
|
|
waited_seconds: float,
|
|
pipe_state: bool | None,
|
|
cause: str,
|
|
) -> str:
|
|
"""Build a startup error message with enough pipe diagnostics to debug."""
|
|
|
|
return (
|
|
f"Output pipe never became ready for {job_id}: {cause}; "
|
|
f"waited {waited_seconds:.3f}s; "
|
|
f"ready-file={ready_file}; "
|
|
f"tmux #{{pane_pipe}}={self._format_pane_pipe_state(pipe_state)}; "
|
|
f"pipe-error.log={self._pipe_error_log_summary(ready_file.parent)}"
|
|
)
|
|
|
|
def _format_pane_pipe_state(self, pipe_state: bool | None) -> str:
|
|
if pipe_state is True:
|
|
return "1"
|
|
if pipe_state is False:
|
|
return "0"
|
|
return "pane-missing"
|
|
|
|
def _pipe_error_log_summary(self, job_dir: Path) -> str:
|
|
"""Summarize sanitizer stderr without flooding API error responses."""
|
|
|
|
error_log = pipe_error_log_path(job_dir)
|
|
if not error_log.exists():
|
|
return f"{error_log} missing"
|
|
stderr_text = error_log.read_text(encoding="utf-8", errors="replace").strip()
|
|
if not stderr_text:
|
|
return f"{error_log} empty"
|
|
summary = " | ".join(
|
|
line.strip() for line in stderr_text.splitlines() if line.strip()
|
|
)
|
|
if not summary:
|
|
return f"{error_log} contains only whitespace"
|
|
if len(summary) > 240:
|
|
summary = f"{summary[:237]}..."
|
|
return f"{error_log}: {summary}"
|
|
|
|
async def _materialize_status_view(
|
|
self,
|
|
job_id: str,
|
|
*,
|
|
session_exists: bool,
|
|
pipe_active: bool | None,
|
|
) -> JobStatusView:
|
|
"""Materialize the current API status view from SQLite + live tmux state.
|
|
|
|
Priority/invariants:
|
|
- terminal SQLite status wins and is preserved
|
|
- if SQLite already has an `exit_code` on a non-terminal row, materialize
|
|
`exited`
|
|
- if normal-exit metadata and `.pipe-drained` already exist, recover the
|
|
drained normal exit immediately even when the finalizer has not yet
|
|
committed SQLite state
|
|
- if normal-exit metadata exists and neither `.pipe-drained` nor
|
|
`.pipe-failed` exists, keep the non-terminal row instead of guessing
|
|
`lost` while the pipe finalizer is still responsible for the eventual
|
|
`runner-exit` commit
|
|
- if a live tmux session exists but the output pipe is known-dead,
|
|
conditionally materialize `failed(reason=pipe_failed)`
|
|
- if no live session exists and the row is not protected by the local
|
|
startup reservation, conditionally materialize `lost`
|
|
|
|
This helper is the main reconciliation point between live tmux probes and
|
|
persisted SQLite state. It must never revive terminal rows and must never
|
|
convert an ambiguous startup race into `lost` while the current `run_job`
|
|
path still owns that startup window.
|
|
"""
|
|
|
|
row = await self._get_job_row(job_id)
|
|
status = JobStatusName(row.status)
|
|
|
|
if is_terminal_status(status):
|
|
if row.ended_at is None:
|
|
row = await self._transition_status(
|
|
job_id,
|
|
allowed_from={status},
|
|
target=status,
|
|
ended_at=format_timestamp(),
|
|
)
|
|
elif row.exit_code is not None:
|
|
row = await self._transition_status(
|
|
job_id,
|
|
allowed_from={
|
|
JobStatusName.CREATED,
|
|
JobStatusName.STARTING,
|
|
JobStatusName.RUNNING,
|
|
},
|
|
target=JobStatusName.EXITED,
|
|
ended_at=row.ended_at or format_timestamp(),
|
|
)
|
|
elif (drained_exit := self._drained_normal_exit_metadata(job_id)) is not None:
|
|
exit_code, ended_at = drained_exit
|
|
await self.record_runner_exit(job_id, exit_code, ended_at)
|
|
row = await self._get_job_row(job_id)
|
|
elif session_exists:
|
|
if pipe_active is False:
|
|
if (
|
|
status
|
|
in {
|
|
JobStatusName.CREATED,
|
|
JobStatusName.STARTING,
|
|
}
|
|
and job_id in self._starting_jobs
|
|
):
|
|
pass
|
|
else:
|
|
row = await self._transition_status(
|
|
job_id,
|
|
allowed_from={
|
|
JobStatusName.CREATED,
|
|
JobStatusName.STARTING,
|
|
JobStatusName.RUNNING,
|
|
},
|
|
target=JobStatusName.FAILED,
|
|
reason="pipe_failed",
|
|
message="The tmux output pipe stopped while the job was still running.",
|
|
ended_at=format_timestamp(),
|
|
)
|
|
elif (
|
|
status in {JobStatusName.CREATED, JobStatusName.STARTING}
|
|
and job_id not in self._starting_jobs
|
|
):
|
|
row = await self._transition_status(
|
|
job_id,
|
|
allowed_from={JobStatusName.CREATED, JobStatusName.STARTING},
|
|
target=JobStatusName.RUNNING,
|
|
require_exit_code_null=True,
|
|
)
|
|
else:
|
|
if self._normal_exit_commit_pending(job_id):
|
|
pass
|
|
elif not (
|
|
status in {JobStatusName.CREATED, JobStatusName.STARTING}
|
|
and job_id in self._starting_jobs
|
|
):
|
|
row = await self._transition_status(
|
|
job_id,
|
|
allowed_from={
|
|
JobStatusName.CREATED,
|
|
JobStatusName.STARTING,
|
|
JobStatusName.RUNNING,
|
|
},
|
|
target=JobStatusName.LOST,
|
|
reason="tmux_session_missing",
|
|
message="The dedicated tmux session is no longer present.",
|
|
ended_at=format_timestamp(),
|
|
)
|
|
|
|
return self._status_view_from_row(row)
|
|
|
|
async def _transition_status(
|
|
self,
|
|
job_id: str,
|
|
*,
|
|
allowed_from: set[JobStatusName],
|
|
target: JobStatusName,
|
|
allow_override_from: set[JobStatusName] | None = None,
|
|
require_exit_code_null: bool = False,
|
|
reason: str | None = None,
|
|
message: str | None = None,
|
|
ended_at: str | None = None,
|
|
) -> JobRow:
|
|
"""Conditionally transition a job row through a SQLite CAS update.
|
|
|
|
This is the SQLite replacement for the old JSON+lock compare-and-swap
|
|
flow. The method performs a single conditional `UPDATE ... WHERE ...`
|
|
against the current row and returns the post-update materialized row.
|
|
|
|
Invariants:
|
|
- non-terminal states may only advance from explicitly allowed source
|
|
states
|
|
- callers can require `exit_code IS NULL` so late runner exits do not let
|
|
startup paths overwrite a concurrently materialized terminal outcome
|
|
- terminal transitions preserve first-writer semantics via the WHERE
|
|
clause and fill `ended_at` only when needed
|
|
- when the conditional update affects zero rows, the method returns the
|
|
current persisted row instead of forcing a stale overwrite
|
|
"""
|
|
|
|
now = format_timestamp()
|
|
allowed_statuses = {status.value for status in allowed_from}
|
|
if allow_override_from is not None:
|
|
allowed_statuses.update(status.value for status in allow_override_from)
|
|
|
|
values: dict[str, Any] = {
|
|
"status": target.value,
|
|
"updated_at": now,
|
|
"reason": reason,
|
|
"message": message,
|
|
}
|
|
status_col = cast(Any, JobRow.status)
|
|
job_id_col = cast(Any, JobRow.job_id)
|
|
started_at_col = cast(Any, JobRow.started_at)
|
|
ended_at_col = cast(Any, JobRow.ended_at)
|
|
exit_code_col = cast(Any, JobRow.exit_code)
|
|
if target in {JobStatusName.STARTING, JobStatusName.RUNNING}:
|
|
values["started_at"] = case(
|
|
(started_at_col.is_(None), now),
|
|
else_=started_at_col,
|
|
)
|
|
if is_terminal_status(target):
|
|
values["ended_at"] = case(
|
|
(ended_at_col.is_(None), ended_at or now),
|
|
else_=ended_at_col,
|
|
)
|
|
|
|
async with self._session_factory() as session:
|
|
stmt = update(JobRow).where(
|
|
job_id_col == job_id, status_col.in_(allowed_statuses)
|
|
)
|
|
if require_exit_code_null:
|
|
stmt = stmt.where(exit_code_col.is_(None))
|
|
result = await session.execute(stmt.values(**values))
|
|
await session.commit()
|
|
if cast(Any, result).rowcount == 0:
|
|
return await self._get_job_row(job_id)
|
|
return await self._get_job_row(job_id)
|
|
|
|
async def _live_runtime_state(self, job_id: str) -> tuple[bool, bool | None]:
|
|
"""Read tmux session/pipe liveness for one job.
|
|
|
|
If the pane disappears between the session probe and the pipe probe, this
|
|
helper degrades to `(False, None)` so reconciliation treats the job as a
|
|
missing-session case rather than a `pipe_failed` case.
|
|
"""
|
|
|
|
session_exists = await self._tmux.session_exists(job_session_name(job_id))
|
|
if not session_exists:
|
|
return False, None
|
|
pipe_active = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
if pipe_active is None:
|
|
return False, None
|
|
return True, pipe_active
|
|
|
|
async def _insert_job_row(self, row: JobRow) -> bool:
|
|
async with self._session_factory() as session:
|
|
session.add(row)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError:
|
|
await session.rollback()
|
|
return False
|
|
return True
|
|
|
|
async def _delete_job_row(self, job_id: str) -> None:
|
|
async with self._session_factory() as session:
|
|
result = await session.execute(
|
|
delete(JobRow).where(cast(Any, JobRow.job_id) == job_id)
|
|
)
|
|
if cast(Any, result).rowcount == 0:
|
|
raise ShellctlServerError(
|
|
404, "job_not_found", f"Unknown job id: {job_id}"
|
|
)
|
|
await session.commit()
|
|
|
|
async def _get_job_row(self, job_id: str) -> JobRow:
|
|
async with self._session_factory() as session:
|
|
row = await session.get(JobRow, job_id)
|
|
if row is None:
|
|
raise ShellctlServerError(404, "job_not_found", f"Unknown job id: {job_id}")
|
|
return row
|
|
|
|
async def _list_job_rows(
|
|
self, *, statuses: set[JobStatusName] | None = None
|
|
) -> list[JobRow]:
|
|
created_at_col = cast(Any, JobRow.created_at)
|
|
status_col = cast(Any, JobRow.status)
|
|
async with self._session_factory() as session:
|
|
stmt = select(JobRow).order_by(created_at_col.desc())
|
|
if statuses is not None:
|
|
stmt = stmt.where(status_col.in_([status.value for status in statuses]))
|
|
rows = (await session.execute(stmt)).scalars().all()
|
|
return list(rows)
|
|
|
|
def _status_view_from_row(self, row: JobRow) -> JobStatusView:
|
|
status = JobStatusName(row.status)
|
|
output_path = self._output_log_path(row)
|
|
offset = output_path.stat().st_size if output_path.exists() else 0
|
|
return JobStatusView(
|
|
job_id=row.job_id,
|
|
status=status,
|
|
done=is_terminal_status(status),
|
|
exit_code=row.exit_code,
|
|
created_at=row.created_at,
|
|
started_at=row.started_at,
|
|
ended_at=row.ended_at,
|
|
offset=offset,
|
|
)
|
|
|
|
def _job_result_from_view(
|
|
self,
|
|
view: JobStatusView,
|
|
row: JobRow,
|
|
window: OutputWindow,
|
|
*,
|
|
truncated: bool | None = None,
|
|
) -> JobResult:
|
|
return JobResult(
|
|
job_id=view.job_id,
|
|
done=view.done,
|
|
status=view.status,
|
|
exit_code=view.exit_code,
|
|
output_path=str(self._output_log_path(row).resolve()),
|
|
output=window.output,
|
|
offset=window.offset,
|
|
truncated=window.truncated if truncated is None else truncated,
|
|
)
|
|
|
|
def _output_log_path(self, row: JobRow) -> Path:
|
|
return self.config.state_dir / row.output_path
|
|
|
|
def _artifact_dir(self, job_id: str) -> Path:
|
|
return self.config.jobs_dir / job_id
|
|
|
|
def _normal_exit_commit_pending(self, job_id: str) -> bool:
|
|
"""Check whether a normal exit is still waiting for pipe drain + commit."""
|
|
|
|
job_dir = self._artifact_dir(job_id)
|
|
return (
|
|
runner_exit_code_path(job_dir).exists()
|
|
and runner_ended_at_path(job_dir).exists()
|
|
and not pipe_drained_path(job_dir).exists()
|
|
and not pipe_failed_path(job_dir).exists()
|
|
)
|
|
|
|
def _drained_normal_exit_metadata(self, job_id: str) -> tuple[int, str] | None:
|
|
"""Read drained normal-exit metadata after successful sanitize drain."""
|
|
|
|
job_dir = self._artifact_dir(job_id)
|
|
drained_path = pipe_drained_path(job_dir)
|
|
exit_code_path = runner_exit_code_path(job_dir)
|
|
ended_at_path = runner_ended_at_path(job_dir)
|
|
if (
|
|
not drained_path.exists()
|
|
or not exit_code_path.exists()
|
|
or not ended_at_path.exists()
|
|
):
|
|
return None
|
|
raw_exit_code = exit_code_path.read_text(encoding="utf-8").strip()
|
|
raw_ended_at = ended_at_path.read_text(encoding="utf-8").strip()
|
|
if not raw_exit_code or not raw_ended_at:
|
|
return None
|
|
return int(raw_exit_code), raw_ended_at
|
|
|
|
def _allocate_job_dir(self) -> tuple[str, Path]:
|
|
"""Atomically allocate a unique artifact directory for a new job.
|
|
|
|
The proposal requires `mkdir jobs/<job_id>` itself to be the collision
|
|
detector. We therefore create the directory inside this helper and retry
|
|
on `FileExistsError` instead of using a separate `exists()` pre-check.
|
|
"""
|
|
|
|
for _ in range(20):
|
|
job_id = generate_job_id()
|
|
job_dir = self.config.jobs_dir / job_id
|
|
try:
|
|
job_dir.mkdir(mode=0o700)
|
|
return job_id, job_dir
|
|
except FileExistsError:
|
|
continue
|
|
raise ShellctlServerError(
|
|
500, "job_id_collision", "Failed to allocate a unique job id"
|
|
)
|
|
|
|
def _resolve_cwd(self, raw_cwd: str | None) -> Path:
|
|
cwd = (
|
|
Path(raw_cwd).expanduser()
|
|
if raw_cwd is not None
|
|
else self.config.default_cwd.expanduser()
|
|
)
|
|
cwd = cwd.resolve()
|
|
if not cwd.exists() or not cwd.is_dir():
|
|
raise ShellctlServerError(
|
|
400, "invalid_cwd", f"cwd is not a directory: {cwd}"
|
|
)
|
|
return cwd
|
|
|
|
def _resolve_env(self, raw_env: dict[str, str] | None) -> dict[str, str]:
|
|
"""Return a detached environment overlay for the generated runner.
|
|
|
|
`RunJobRequest` validates names/values before the service sees them.
|
|
The overlay augments the runner's inherited environment after shellctl
|
|
scrubs its own control variables, so explicit request values can be used
|
|
without replacing ambient entries like `PATH`.
|
|
"""
|
|
|
|
return dict(raw_env or {})
|
|
|
|
def _validate_offset(self, output_path: Path, offset: int) -> None:
|
|
size = output_path.stat().st_size if output_path.exists() else 0
|
|
if offset > size:
|
|
raise ShellctlServerError(
|
|
400,
|
|
"invalid_offset",
|
|
f"offset {offset} exceeds current file size {size}",
|
|
)
|
|
|
|
@staticmethod
|
|
def _ensure_dir(path: Path) -> None:
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path.chmod(0o700)
|
|
|
|
def _install_runner(self) -> None:
|
|
self.config.runner_path.write_text(
|
|
self._runner_script_source(), encoding="utf-8"
|
|
)
|
|
mode = self.config.runner_path.stat().st_mode
|
|
self.config.runner_path.chmod(mode | stat.S_IXUSR)
|
|
|
|
def _runner_script_source(self) -> str:
|
|
"""Build the bash runner installed into the shellctl runtime directory.
|
|
|
|
The wrapper still handles gate waiting and exit metadata in bash, but it
|
|
delegates launch setup to Python so per-job env overlays can be loaded
|
|
from JSON without shell-escaping arbitrary values into `export`
|
|
statements. The Python helper then `exec`s the target process instead of
|
|
waiting on it, which keeps `SIGINT` behavior identical to running the
|
|
script directly in the tmux pane. The bootstrap uses `python -c ...`
|
|
instead of a stdin-fed heredoc so the exec'd job still inherits the tmux
|
|
pane's PTY on fd 0 and can receive later `send_input()` data.
|
|
"""
|
|
|
|
auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
|
|
bootstrap_source = shlex.quote(
|
|
"""
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
script_path = Path(sys.argv[1])
|
|
cwd = sys.argv[2]
|
|
env_path = Path(sys.argv[3])
|
|
|
|
env = os.environ.copy()
|
|
if env_path.exists():
|
|
env.update(json.loads(env_path.read_text(encoding="utf-8")))
|
|
|
|
try:
|
|
os.chdir(cwd)
|
|
except OSError:
|
|
raise SystemExit(111)
|
|
|
|
with script_path.open("r", encoding="utf-8") as handle:
|
|
first_line = handle.readline()
|
|
|
|
if first_line.startswith("#!"):
|
|
script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR)
|
|
argv = [str(script_path)]
|
|
else:
|
|
argv = ["sh", str(script_path)]
|
|
|
|
try:
|
|
os.execvpe(argv[0], argv, env)
|
|
except FileNotFoundError as exc:
|
|
print(f"{argv[0]}: {exc.strerror}", file=sys.stderr)
|
|
raise SystemExit(127) from exc
|
|
except OSError as exc:
|
|
print(f"{argv[0]}: {exc.strerror}", file=sys.stderr)
|
|
raise SystemExit(126) from exc
|
|
""".strip()
|
|
)
|
|
return f"""#!/usr/bin/env bash
|
|
set -uo pipefail
|
|
|
|
JOB_DIR="$1"
|
|
JOB_ID="$2"
|
|
CWD="$3"
|
|
SCRIPT_PATH="$JOB_DIR/script"
|
|
ENV_PATH="$JOB_DIR/{JOB_ENV_FILENAME}"
|
|
START_GATE="$JOB_DIR/start-gate"
|
|
RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
|
|
RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
|
|
|
|
write_atomic() {{
|
|
local dest="$1"
|
|
local value="$2"
|
|
local tmp="${{dest}}.tmp.$$"
|
|
printf '%s\n' "$value" > "$tmp"
|
|
mv "$tmp" "$dest"
|
|
}}
|
|
|
|
while [ ! -e "$START_GATE" ]; do
|
|
sleep 0.05
|
|
done
|
|
|
|
unset TMUX
|
|
unset SHELLCTL_STATE_DIR
|
|
unset SHELLCTL_RUNTIME_DIR
|
|
unset SHELLCTL_TMUX_SOCKET
|
|
unset SHELLCTL_RUNNER
|
|
unset {auth_env}
|
|
|
|
{shlex.quote(sys.executable)} -c {bootstrap_source} "$SCRIPT_PATH" "$CWD" "$ENV_PATH"
|
|
EXIT_CODE=$?
|
|
|
|
ENDED_AT="$({shlex.quote(sys.executable)} - <<'PY'
|
|
from datetime import UTC, datetime
|
|
print(datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'))
|
|
PY
|
|
)"
|
|
|
|
write_atomic "$RUNNER_EXIT_CODE_PATH" "$EXIT_CODE"
|
|
write_atomic "$RUNNER_ENDED_AT_PATH" "$ENDED_AT"
|
|
|
|
exit "$EXIT_CODE"
|
|
"""
|
|
|
|
|
|
__all__ = ["ShellctlService", "anyio", "shutil"]
|