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:
@@ -47,6 +47,9 @@ LABELS: dict[str, str] = {
|
||||
"file_list": "List",
|
||||
"plan_submit": "Plan",
|
||||
"plan_update": "Plan updated",
|
||||
"job_output": "Job output",
|
||||
"job_list": "Jobs",
|
||||
"job_stop": "Job stopped",
|
||||
# The web.
|
||||
"web_search": "Web search",
|
||||
"fetch": "Fetch",
|
||||
@@ -77,6 +80,9 @@ ICONS: dict[str, str] = {
|
||||
"file_list": "folder",
|
||||
"plan_submit": "check",
|
||||
"plan_update": "check",
|
||||
"job_output": "clock",
|
||||
"job_list": "dots",
|
||||
"job_stop": "stop-circle",
|
||||
"web_search": "globe",
|
||||
"fetch": "link",
|
||||
"knowledge_search": "archive",
|
||||
@@ -126,6 +132,7 @@ ACTIONS: dict[str, str] = {
|
||||
"skill_get": "Read a skill",
|
||||
"skill_create": "Write a skill",
|
||||
"skill_edit": "Change a skill",
|
||||
"job_stop": "Stop a background job",
|
||||
}
|
||||
|
||||
# Which argument is the thing being agreed to. Shown verbatim and escaped on the
|
||||
@@ -141,6 +148,7 @@ DETAIL_KEYS: dict[str, str] = {
|
||||
"web_search": "query",
|
||||
"knowledge_search": "query",
|
||||
"notes_search": "query",
|
||||
"job_stop": "id",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user