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:
Jaroslav Beneš
2026-08-03 14:15:49 +02:00
parent bf9287493b
commit 89d2d6ebfd
8 changed files with 1033 additions and 31 deletions
+8
View File
@@ -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",
}