repl: background CMD&: with handle/poll (closes #8)

Builds, long-running network calls, and file watches no longer block
the turn. A new "CMD&: <cmd>" marker (analogue of CMD:) tells the REPL
to spawn the command in the background, return immediately, and poll
for completion between user inputs.

Process model: shell-wrapped to avoid needing fork()/execv() FFI.

  nohup sh -c '(<cmd>) > <log> 2>&1; echo $? > <status>' </dev/null
       >/dev/null 2>&1 & echo $!

The child is reparented to init; we hold only the PID and the path to
the .status sidecar. Completion is detected by the .status file
existing (the wrapper writes it as its last act). No waitpid needed —
the child isn't ours after the popen subshell exits.

Storage: <history.dir>/bg/<id>.log + <id>.status. The directory is
created lazily at startup (mkdir -p). Requires history.dir to be
configured; without it CMD&: emits an error status and the model
sees an "[bg failed to start]" exec-output note.

check_bg_done() runs at the top of each main-loop iteration alongside
check_every_due(). When a job is detected as exited, the REPL:
  - emits a status line "[bg:<id> exited <code>, <bytes>, <secs>s wall] <cmd>"
  - appends the same string to ctx as exec output, so the model sees
    the completion on its next turn (natural follow-up: "ok the build
    finished; let me check the log")

Meta surface:
  :bg-spawn <cmd>       start a bg job directly (no AI needed; also
                        useful for testing without depending on the
                        model emitting CMD&:)
  :bg-list              show running/done jobs (id, pid, state, runtime, cmd)
  :bg-output <id>       dump the log file to stdout
  :bg-kill <id>         SIGTERM (note: only delivers if the PID is
                        still the actual command — long-lived shells
                        may need pkill by name)

Scope (deliberately limited for v1):
  - No callback-mode readline: bg completion detection is pre-prompt,
    not mid-readline. If a build finishes while the user is typing,
    notification comes when they hit Enter.
  - Permission policy DSL (#9) does NOT apply to CMD&: — the
    asynchronous gating model wasn't designed for the y/N flow.
    Filed as follow-up if needed.
  - Norris not extended: helpers.exec_cmd is still synchronous; the
    planner doesn't dispatch bg jobs.
  - Plan mode interaction: CMD&: in plan mode emits "PLAN: & <cmd>"
    and a "[plan] would bg-run: <cmd>" exec-output note, no spawn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:25:55 +00:00
parent 67d80e1047
commit f94d16fc89
3 changed files with 190 additions and 1 deletions
+5
View File
@@ -71,6 +71,11 @@ return {
-- post_cmd = (os.getenv("HOME") or ".") .. "/.aish/hooks/post-cmd",
-- },
-- Issue #8: background CMD (CMD&: marker). Requires history.dir set
-- (logs land at <history.dir>/bg/<id>.log + .status sidecar). The
-- feature is always-on once history.dir exists — no config flag — but
-- only fires when the model emits "CMD&: " or the user runs :bg-spawn.
-- Issue #9: permission policy DSL for AI-suggested CMD: lines. When set,
-- supersedes shell.confirm_cmd. Patterns are Lua patterns (NOT regex)
-- per substrate invariant §3 (no compiled extensions). Priority order:
+11 -1
View File
@@ -127,11 +127,21 @@ end
-- Extract `CMD: ` lines from an assistant response per the §6 broker contract.
-- The "CMD: " prefix is a §3 substrate invariant: exact prefix, single space,
-- start-of-line only. Leading whitespace before CMD: does NOT match.
-- "CMD&: " lines are issue #8 background variants — extracted separately so
-- repl.lua can route them to the bg spawner instead of the synchronous gate.
function M.extract_cmd_lines(text)
local cmds = {}
for line in (text or ""):gmatch("[^\n]+") do
local cmd = line:match("^CMD: (.*)$")
-- Skip whitespace-only / empty bodies; "CMD: " alone is degenerate.
if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
end
return cmds
end
function M.extract_cmd_bg_lines(text)
local cmds = {}
for line in (text or ""):gmatch("[^\n]+") do
local cmd = line:match("^CMD&: (.*)$")
if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
end
return cmds
+174
View File
@@ -142,6 +142,10 @@ Meta commands:
:every <i> <prompt> schedule a recurring prompt (i: 30s | 5m | 2h)
:every list show scheduled recurring prompts
:every cancel <id> remove a scheduled prompt
:bg-spawn <cmd> start a background job directly (no AI needed)
:bg-list list background jobs (issued via CMD&: or :bg-spawn)
:bg-output <id> dump the log of a background job
:bg-kill <id> SIGTERM a background job
:help this message
]]
@@ -161,6 +165,12 @@ function M.run(config)
-- to Norris mode (Norris has its own halt protocol).
local plan_mode = false
-- Forward decl (issue #8): the bg spawn closure is defined deeper in
-- M.run alongside the meta dispatch, but ask_ai needs to call it when
-- routing CMD&: lines. Lua looks up names at call time; the closure
-- has to exist as a local in scope BEFORE ask_ai is declared.
local _bg_spawn
-- Phase 5: render the evicted turns into a compact transcript for
-- the summarizer prompt. Same shape as :memory summarize uses.
local function render_evicted(turns)
@@ -777,6 +787,28 @@ function M.run(config)
if doit then run_shell(cmd) end
end
end
-- Issue #8: CMD&: extraction — spawn each as a background job.
-- No confirm gate in v1 (the model issuing CMD&: is opting into the
-- async path; permission policy is still bypassed there. Revisit
-- once #9 is generalized beyond the synchronous CMD: gate).
for _, cmd in ipairs(executor.extract_cmd_bg_lines(final_resp)) do
if plan_mode then
renderer.status(("PLAN: & %s"):format(cmd))
ctx:append_exec_output(("[plan] would bg-run: %s"):format(cmd))
else
local job, err = _bg_spawn(cmd)
if not job then
renderer.status(("bg spawn failed: %s"):format(tostring(err)))
ctx:append_exec_output(("[bg failed to start]: %s"):format(cmd))
else
local note = ("[bg:%d started pid=%d]: %s")
:format(job.id, job.pid, cmd)
renderer.status(note)
ctx:append_exec_output(note)
end
end
end
end
local function shutdown_session()
@@ -1540,9 +1572,151 @@ function M.run(config)
:format(id, secs, job_model, p))
end
-- Issue #8: background CMD (CMD&: marker). Spawn via a shell wrapper
-- that captures stdout+stderr to <history.dir>/bg/<id>.log and the
-- exit code to <id>.status. We poll with kill -0; on completion read
-- the .status sidecar. No fork()/execv() FFI required — relies on POSIX
-- shell semantics. Reparented child is owned by init; we treat it as
-- "managed" via the PID and the status file only.
local bg_jobs = {} -- { {id, pid, cmd, started_at, log_path, status_path, exited} }
local next_bg_id = 1
local bg_dir = history_dir and (history_dir .. "/bg") or nil
if bg_dir then os.execute(("mkdir -p %q 2>/dev/null"):format(bg_dir)) end
local function _bg_shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
_bg_spawn = function(cmd)
if not bg_dir then
return nil, "background CMD requires history.dir to be configured"
end
local id = next_bg_id; next_bg_id = next_bg_id + 1
local log_path = ("%s/%d.log"):format(bg_dir, id)
local status_path = ("%s/%d.status"):format(bg_dir, id)
-- Wrapper: redirect, capture exit, write status. nohup + </dev/null
-- so the child survives our exit and doesn't compete for stdin.
-- Use `(...) &` so the subshell that wraps the exit-capture is
-- itself backgrounded; we echo $! to capture its PID.
local wrapper = ("nohup sh -c %s </dev/null >/dev/null 2>&1 & echo $!"):format(
_bg_shq(("(%s) > %s 2>&1; echo $? > %s"):format(
cmd, _bg_shq(log_path), _bg_shq(status_path))))
local pipe = io.popen(wrapper)
local pid_str = pipe and pipe:read("*l")
if pipe then pipe:close() end
local pid = tonumber(pid_str)
if not pid then
return nil, "failed to spawn (no PID returned)"
end
local job = {
id = id,
pid = pid,
cmd = cmd,
started_at = os.time(),
log_path = log_path,
status_path = status_path,
exited = false,
}
bg_jobs[#bg_jobs + 1] = job
return job
end
local function _bg_status_check(job)
if job.exited then return end
-- Read status file: presence means the wrapper finished writing
-- the exit code. If absent and PID is still alive, job is running.
local f = io.open(job.status_path, "rb")
if f then
local s = f:read("*l") or ""
f:close()
job.exit_code = tonumber(s) or -1
job.exited = true
job.exited_at = os.time()
local lf = io.open(job.log_path, "rb")
job.log_bytes = 0
if lf then
lf:seek("end"); job.log_bytes = lf:seek(); lf:close()
end
end
end
local function _fmt_bytes(n)
if n < 1024 then return ("%dB"):format(n) end
if n < 1024*1024 then return ("%.1fKB"):format(n/1024) end
return ("%.1fMB"):format(n/(1024*1024))
end
local function check_bg_done()
for _, job in ipairs(bg_jobs) do
if not job.exited then
_bg_status_check(job)
if job.exited then
local wall = (job.exited_at or os.time()) - job.started_at
local summary = ("[bg:%d exited %d, %s, %ds wall] %s")
:format(job.id, job.exit_code,
_fmt_bytes(job.log_bytes or 0), wall, job.cmd)
renderer.status(summary)
-- Feed back into context so the model sees completion
-- on the next ai turn — same channel as foreground exec.
ctx:append_exec_output(summary)
end
end
end
end
meta["bg-spawn"] = function(args)
local cmd = (args or ""):match("^%s*(.-)%s*$")
if cmd == "" then renderer.status("usage: :bg-spawn <cmd>"); return end
local job, err = _bg_spawn(cmd)
if not job then
renderer.status("bg spawn failed: " .. tostring(err))
else
renderer.status(("started #%d pid=%d: %s")
:format(job.id, job.pid, cmd))
end
end
meta["bg-list"] = function()
if #bg_jobs == 0 then renderer.status("(no bg jobs)"); return end
check_bg_done()
renderer.status(("bg jobs (%d):"):format(#bg_jobs))
for _, j in ipairs(bg_jobs) do
local state
if j.exited then
state = ("exit=%d %ds"):format(j.exit_code,
(j.exited_at - j.started_at))
else
local age = os.time() - j.started_at
state = ("running pid=%d %ds"):format(j.pid, age)
end
io.write((" #%-3d %s %s\n"):format(j.id, state, j.cmd))
end
end
meta["bg-output"] = function(args)
local id = tonumber(args:match("^%s*(%d+)"))
if not id then renderer.status("usage: :bg-output <id>"); return end
local job
for _, j in ipairs(bg_jobs) do if j.id == id then job = j; break end end
if not job then renderer.status("no such bg job: #" .. id); return end
local f = io.open(job.log_path, "rb")
if not f then renderer.status("(no log file yet)"); return end
io.write(f:read("*a") or ""); f:close()
if not job.log_path:match("\n$") then io.write("\n") end
end
meta["bg-kill"] = function(args)
local id = tonumber(args:match("^%s*(%d+)"))
if not id then renderer.status("usage: :bg-kill <id>"); return end
for _, j in ipairs(bg_jobs) do
if j.id == id then
if j.exited then
renderer.status(("#%d already exited"):format(id))
else
os.execute(("kill %d 2>/dev/null"):format(j.pid))
renderer.status(("sent SIGTERM to #%d (pid %d)"):format(id, j.pid))
end
return
end
end
renderer.status("no such bg job: #" .. id)
end
-- Main loop.
while true do
check_every_due()
check_bg_done()
local line = rl.readline(prompt())
if line == nil then -- EOF (Ctrl-D on empty line)
io.write("\n")