Spaces:
Sleeping
Sleeping
File size: 8,989 Bytes
6303ae6 b5d28f1 357ee36 b5d28f1 357ee36 6303ae6 357ee36 6303ae6 b5d28f1 357ee36 b5d28f1 6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 b5d28f1 357ee36 b5d28f1 6303ae6 b5d28f1 357ee36 6303ae6 357ee36 6303ae6 357ee36 b5d28f1 357ee36 b5d28f1 6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """Run long operations in a background daemon thread.
Streamlit reruns the whole script on every interaction (including clicking a
different step in the sidebar). A long, synchronous operation — generating a
proposal, running the analysis — gets interrupted by that rerun, so the work is
lost. This helper runs the operation on an independent daemon thread whose life
is NOT tied to the script run, keeps the result in a module-level registry, and
lets the UI poll for completion. The user can switch steps freely; the work
keeps going and the result is waiting when they return.
IMPORTANT: the worker function must not touch ``st.session_state`` (Streamlit
calls from a thread with no script-run context fail). Pass everything it needs
as arguments and read the result back on the main thread.
The registry is shared by every visitor this process serves, so each run gets
its own id from :func:`new_task_id`, and the caller keeps that id in its own
``st.session_state``. Only that session can poll or collect the run, and a
session that forgets the id (a new job, a re-run) has abandoned it: clearing a
run that is still going discards whatever it produces.
API-key propagation: the API key now lives only in the per-session store
(``st.session_state``), which a worker thread cannot see. So before spawning the
worker we snapshot the launching session's config overrides on the MAIN thread
and re-install them into the worker's own thread-local (see
``app.config.export_session_overrides`` / ``install_thread_overrides``). This
makes ``get_settings()`` inside the worker resolve the same key/provider/model
the user configured — without leaking it to any other visitor's worker.
Usage log: for the same reason, the AI calls a worker makes cannot be logged to
the session's ``api_usage_log``. The worker logs them to a list of its own
(``app.services.llm_client.set_thread_usage_log``) that goes back with the
outcome, and :func:`pop` adds them to the log of the session collecting it.
"""
from __future__ import annotations
import threading
import time
import traceback
import uuid
from datetime import datetime, timezone
from typing import Any, Callable
from app import config
from app.services import diagnostics, llm_client
from app.utils.logging_utils import get_logger
_logger = get_logger("upwork_strategist.background_tasks")
_lock = threading.Lock()
_tasks: dict[str, dict[str, Any]] = {}
# A finished result waits here until its session collects it. One nobody ever
# collects (the tab was closed) is dropped after this long, so the shared
# registry cannot grow for the life of the process.
_RESULT_TTL_SECONDS = 60 * 60
def new_task_id(name: str) -> str:
"""A fresh id for one run of ``name`` (e.g. ``"proposal_generation"``).
A fixed id would be shared by every visitor: one visitor's run would block
another's, and whichever session polled first would collect it.
"""
return f"{name}:{uuid.uuid4().hex}"
def _sweep_expired() -> None:
"""Drop finished results nobody collected in time. Call with ``_lock`` held."""
cutoff = time.monotonic() - _RESULT_TTL_SECONDS
expired = [
task_id
for task_id, t in _tasks.items()
if t.get("finished_at", cutoff) < cutoff
]
for task_id in expired:
del _tasks[task_id]
def _report_crash(task_id: str, exc: Exception) -> None:
"""Log a worker crash and record it for diagnostics. Call in the ``except``.
Without this a crash leaves no trace: the screen only learns that the run
failed. The traceback goes to the server log, inside the message so the
log's redaction filter scrubs it like any other line. The diagnostics
event feeds the "Problem detected" panel and the report a user can share,
so it names the exception's class only — never its message, which can
quote dossier or job text.
"""
task_name = task_id.split(":", 1)[0]
error_type = type(exc).__name__
_logger.error(
"task=%s status=app_error error_type=%s\n%s",
task_name,
error_type,
traceback.format_exc(),
)
# Same record shape as llm_client's, so a report renders one table.
diagnostics.record_event(
{
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"task_name": task_name,
"provider": None,
"model": None,
"used_api": False,
"status": "app_error",
"error_message": None,
"duration_seconds": None,
"estimated_input_tokens": None,
"estimated_output_tokens": None,
"request_id": None,
"http_status": None,
"error_type": error_type,
}
)
def _has_streamlit_runtime() -> bool:
"""True only inside a real Streamlit script run (not tests / bare mode)."""
try:
from streamlit.runtime.scriptrunner import get_script_run_ctx
return get_script_run_ctx() is not None
except Exception: # noqa: BLE001
return False
def start(task_id: str, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> bool:
"""Start ``fn`` under ``task_id``.
In the live app this runs on a daemon thread (so a Streamlit rerun can't
kill it). Outside a real Streamlit runtime — unit tests, bare mode — it
runs SYNCHRONOUSLY so callers that expect an immediate result still work.
Returns True if a new run was started, False if one was already running
(so a double-click or a rerun won't launch duplicate work).
"""
record = {"status": "running", "result": None, "error": None}
with _lock:
_sweep_expired()
cur = _tasks.get(task_id)
if cur and cur.get("status") == "running":
return False
_tasks[task_id] = record
# Snapshot the launching session's config (incl. the session-only API key)
# HERE, on the main thread, while st.session_state is reachable. The worker
# re-installs it into its own thread-local so get_settings() resolves the
# right key. Empty when no session (tests / bare mode).
session_overrides = config.export_session_overrides()
# The usage-log entries of the AI calls this run makes. They go back with
# the outcome, and pop() adds them to the collecting session's log.
usage_log: list[dict] = []
def _finish(outcome: dict[str, Any]) -> None:
# Stored only while this run is still the one registered: a run its
# session cleared (abandoned) must never come back as a result.
outcome["finished_at"] = time.monotonic()
outcome["usage_log"] = usage_log
with _lock:
if _tasks.get(task_id) is record:
_tasks[task_id] = outcome
def _run() -> None:
if session_overrides:
config.install_thread_overrides(session_overrides)
previous_log = llm_client.set_thread_usage_log(usage_log)
try:
res = fn(*args, **kwargs)
_finish({"status": "done", "result": res, "error": None})
except Exception as exc: # noqa: BLE001 - surface as a clean error status
_report_crash(task_id, exc)
# The exception itself goes back too, so the screen can tell a
# provider failure from a fault in the app.
_finish({
"status": "error",
"result": None,
"error": str(exc),
"error_type": type(exc).__name__,
"exc": exc,
})
finally:
llm_client.set_thread_usage_log(previous_log)
if session_overrides:
config.clear_thread_overrides()
if _has_streamlit_runtime():
threading.Thread(target=_run, daemon=True).start()
else:
_run() # synchronous fallback for tests / bare mode
return True
def status(task_id: str) -> dict[str, Any]:
"""Return ``{"status", "result", "error"}`` for ``task_id`` (status idle if unknown).
A failed run also carries ``error_type`` and the exception as ``exc``.
"""
with _lock:
t = _tasks.get(task_id)
return dict(t) if t else {"status": "idle", "result": None, "error": None}
def is_running(task_id: str) -> bool:
return status(task_id).get("status") == "running"
def pop(task_id: str) -> dict[str, Any]:
"""Read and REMOVE a finished task's record (done/error). Running tasks stay.
Call it on the main thread of the session that started the run: the AI
calls the run made are added to that session's usage log here.
"""
with _lock:
t = _tasks.get(task_id)
finished = bool(t and t.get("status") in ("done", "error"))
if finished:
_tasks.pop(task_id, None)
if finished:
llm_client.add_to_usage_log(t.get("usage_log") or ())
return dict(t) if t else {"status": "idle", "result": None, "error": None}
def clear(task_id: str) -> None:
with _lock:
_tasks.pop(task_id, None)
|