Wake the model when a background job finishes
The other half of background execution: a job that finishes while nobody is looking prompts the model back with its result, rather than sitting unread until the model happens to run again. The vehicle is the queue, because it is the only wiring that already delivers a turn into or after a reply. A per-job poller notices completion and calls jobs.wake. If a reply is being written the completion is left queued for that reply's _inject/_drain; if the chat is idle a fresh reply is started to answer it -- the send_queued_now move. All of it under a per-chat lock with no await between the running-check and ensure, so two jobs finishing at once cannot each spin up a generation: the second sees the first's reply already live and leaves its completion for it. That is the invariant the queue exists to hold, reached from outside a request for the first time. The completion is a user-role turn whose content names itself a machine event -- "A background job you started has finished" -- not a bare person turn. _inject sends a queued turn verbatim, so the framing cannot live there; it lives in the words, the way execute_plan quotes the plan, and a tool.background fragment tells the model these arrive and are a machine event rather than the person speaking. The poller reconnects a fresh connection each tick rather than holding one open -- holding one is the exact live-connection state the whole ssh.py/base.py design forbids, and poll is self-healing besides. Bounded by background_max_jobs and a six-hour ceiling, after which the remote job may keep running but we stop watching it. A Job table, and here the terminal/generation "lost on restart" precedent does NOT transfer: those are seconds long with a human watching, a background job is hours long with nobody watching -- the one case a restart forgetting it would silently break the feature's whole promise. So the row lets a lifespan startup hook rehydrate the watcher and wake as if nothing happened. Cancelling a watcher never stops the detached remote job; it runs on and is picked back up. Tested end to end against a real local shell: launch a detached command, poll it to completion through a watcher, and assert the model was woken with the exit code and output -- plus the lock proving two simultaneous completions start one reply, not two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,12 +85,24 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
||||
log.exception("orphaned upload sweep failed")
|
||||
|
||||
# Background jobs that were still running when we last stopped keep running
|
||||
# on their own hosts; pick their watchers back up so the model is still
|
||||
# woken when they finish. Best-effort, and inside the loop so its tasks land
|
||||
# in this event loop.
|
||||
try:
|
||||
from lembas.services.agent.jobs import rehydrate as rehydrate_jobs
|
||||
|
||||
rehydrate_jobs()
|
||||
except Exception: # noqa: BLE001 - a job that cannot be rehydrated is not fatal
|
||||
log.exception("could not rehydrate background jobs")
|
||||
|
||||
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
|
||||
log.info("data directory: %s", settings.data_dir.resolve())
|
||||
yield
|
||||
|
||||
# Replies still being written are cancelled and persisted with whatever
|
||||
# they have, rather than left as permanently unfinished rows.
|
||||
from lembas.services.agent.jobs import shutdown as stop_jobs
|
||||
from lembas.services.agent.terminal import shutdown as stop_terminals
|
||||
from lembas.services.generation import shutdown as stop_generations
|
||||
|
||||
@@ -99,6 +111,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# is cut off mid-command. Every deploy does this, and the panel is told why
|
||||
# rather than left to guess -- see deploy/README.md.
|
||||
await stop_terminals()
|
||||
# Background jobs are the exception: cancelling a watcher does NOT stop the
|
||||
# detached remote job, which keeps running and is rehydrated on the next
|
||||
# start. Only the watching stops here.
|
||||
await stop_jobs()
|
||||
log.info("LLeMbas stopped")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user