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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user