Let a command run in the background instead of being killed
An agent command is one blocking conn.run over a per-call connection, killed the
moment it hits its timeout -- so a ten-minute apt install is impossible, which is
exactly what a user hit. This is the substrate for running it detached instead:
the model can ask for background=true, or a command that outlasts its timeout is
kept running rather than killed, and either way the model gets tools to read and
stop it. Opt-in, off by default, under Admin -> Agents; off is byte-for-byte the
old behaviour.
The mechanism has to survive the connection closing (that is the whole premise
of the per-call model), so a job is a setsid-detached process on the far side,
redirected to a remote logfile and an exit-file; LLeMbas reconnects, as always,
to read it later. services/agent/jobs.py holds the wrappers.
Three things in those wrappers are load-bearing and each was got wrong in the
first sketch:
- The command never touches a quoted shell context. sh -c '<cmd>' shatters the
instant the command contains a quote -- git commit -m 'fix', awk '{…}', sed
's/…/…/' are the common case, and it is an injection hole besides. So the
command is base64-encoded in Python and decoded on the far side into a script
file; it is bytes, never shell syntax.
- The child records its own pid via $$ as its first act, under setsid where it
is the session leader, so job_stop can kill the whole process group. echo $!
from the launcher captures the wrong pid.
- The command's exit status comes from the exit-file, never the wrapper's own
status -- which is ~0 from its trailing rm. Reading the wrapper's status would
mark every job a success.
A command that finishes in time is indistinguishable from a foreground one --
same output, same wording; the difference shows only when it does not, where
instead of "stopped after Ns" it becomes a job id. Auto-convert is its own
sub-switch: with it off, a timeout stays a hard stop and nothing is left
running, because routing the plain case through the detached wrapper would leave
an orphan running past a stop an administrator asked for.
New agent tools job_output/job_list/job_stop, offered only when the feature is
on (the plan_submit gating pattern); job_stop is RISK_EXECUTE since it kills a
process. A job's files are namespaced by the calling chat's id and the wrappers
are always built from it, so a model in one chat cannot even name another's job.
Tested against a real local /bin/sh rather than the fake echo-the-command sshd
fixture, because the shell logic -- setsid, base64, the wait loop, the child
surviving the wait being cut off -- is the whole of the risk. The auto-wake that
prompts the model back when a job finishes is the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,10 @@ async def save_agents(
|
||||
instructions_enabled: bool = Form(False),
|
||||
instructions_chars: int = Form(4000),
|
||||
nudge_unfinished: bool = Form(False),
|
||||
background_enabled: bool = Form(False),
|
||||
background_on_timeout: bool = Form(False),
|
||||
background_notify: bool = Form(False),
|
||||
background_max_jobs: int = Form(5),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
@@ -112,6 +116,10 @@ async def save_agents(
|
||||
"instructions_enabled": instructions_enabled,
|
||||
"instructions_chars": min(max(instructions_chars, 0), 20_000),
|
||||
"nudge_unfinished": nudge_unfinished,
|
||||
"background_enabled": background_enabled,
|
||||
"background_on_timeout": background_on_timeout,
|
||||
"background_notify": background_notify,
|
||||
"background_max_jobs": min(max(background_max_jobs, 1), 100),
|
||||
},
|
||||
key=settings_store.AGENTS,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user