diff --git a/docs/configuration.md b/docs/configuration.md index 0f4c6e2..11ce4fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,6 +40,8 @@ lcode config path # print the file location | `prune` | `true` | When the context is 85% full, first remove old tool output, and summarize the conversation only if that's not enough ([how](how-it-works.md#context-management)) | | `subagents` | `true` | Let the model hand tasks to [subagents](agents.md) with their own context | | `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) | +| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer | +| `notify_after` | `30` | Seconds a request runs before it notifies | | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them | Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and diff --git a/docs/hooks.md b/docs/hooks.md index d808179..8900e32 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -46,7 +46,7 @@ command = "ruff format {path} && ruff check --fix {path}" | `after_tool` | After a tool call | Its output goes to the model when it exits with an error, or always with `feedback = true` | | `after_request` | When a request is done | Its output is shown to you | | `session_start` | When a session starts | Its output is shown, and with `feedback = true` given to the model | -| `notification` | When lcode waits for your answer, and when a request that took over 30 seconds is done | Show a desktop notification, ring a bell | +| `notification` | When lcode waits for your answer, and when a request that ran longer than `notify_after` seconds (30) is done | Send a message, play a sound (lcode shows [desktop notifications](usage.md#notifications) itself) | | Key | | |---|---| diff --git a/docs/usage.md b/docs/usage.md index 713d385..76770a5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -79,6 +79,7 @@ reasoning is on. | `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode | | `/cd DIR` | Change the working directory | | `/todos` | Show the model's task list | +| `/jobs [stop ID]` | The [background commands](#background-commands) the model started; stop one | | `/commit [notes]` | [Commit](git.md#commit) the changes with a message in the repository's style, once you approve it | | `/review [base]` | [Review](git.md#review) the uncommitted changes, or the branch against a base | | `/pr [base] [notes]` | Push the branch and open a GitHub [pull request](git.md#pr), once you approve it | @@ -94,7 +95,8 @@ reasoning is on. | `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches | | `glob` | Find files by pattern, newest first | | `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) | -| `bash` | Run a shell command with live output, a timeout and a persistent working directory | +| `bash` | Run a shell command with live output, a timeout and a persistent working directory; with `background`, keep it running ([details](#background-commands)) | +| `bash_output`, `bash_stop` | Read what a background command printed since last time; stop it | | `todo_write` | Keep a visible task list for multi-step work | | `web_search` | Search the web for current information (needs a [search provider](#web-search)) | | `web_fetch` | Read a web page or text file by URL as clean text | @@ -110,6 +112,38 @@ reasoning is on. lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since it was read, so the model always edits the current version. +## Background commands + +Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts +one with `bash` and `background: true` and carries on working. It reads what the command printed +since last time with `bash_output`, and stops it with `bash_stop`. + +```text + $ npm run dev (in the background) + > vite + VITE v6.0.0 ready in 312 ms + ⎿ background job 1, /jobs to see it +``` + +- `/jobs` lists the background commands; `/jobs stop 1` stops one. +- They all stop when the session ends. +- Anything else a command starts with `&` is stopped when that command ends. Only background + commands keep running, so no process outlives the session unnoticed. +- Background commands ask for permission like any other command, and run in the + [sandbox](sandbox.md) when it's on. + +## Notifications + +Local models take a while, so you'll often switch to something else during a request. lcode shows +a desktop notification: +- when a request that ran longer than 30 seconds is done; +- when, during such a request, lcode waits for your answer (a permission question or a plan). + +It uses `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell where neither +works. Change the time with `lcode config set notify_after 60`, or turn notifications off with +`lcode config set notify false`. For something else, such as a sound or a phone message, use a +[notification hook](hooks.md#hooks). + ## Images lcode can look at screenshots, mockups, diagrams and photos: diff --git a/src/lcode/acp.py b/src/lcode/acp.py index 3b9c010..0f5117a 100644 --- a/src/lcode/acp.py +++ b/src/lcode/acp.py @@ -46,7 +46,7 @@ "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search", "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think", - "present_plan": "switch_mode", + "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute", } # fmt: skip STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"} MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt diff --git a/src/lcode/agent.py b/src/lcode/agent.py index 3c76650..790ffa1 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -22,11 +22,12 @@ from rich.panel import Panel from rich.text import Text -from lcode import catalog, codesearch, extensions, limits, planning, repomap, sessions, subagents, vision, web +from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web from lcode import context as context_tools from lcode import memory as memory_notes from lcode.checkpoints import Checkpoints from lcode.config import format_tokens +from lcode.jobs import Jobs from lcode.mcp import McpManager from lcode.ollama import Ollama, OllamaError from lcode.permissions import Permissions @@ -212,6 +213,8 @@ class Settings: lsp: str = "off" # auto: use the installed language servers (lcode.lsp) repo_map: bool = False # the repository map (lcode.repomap) embed_model: str = "off" # semantic code search (lcode.codesearch): auto, off or a model + notify: bool = False # desktop notifications after long requests (lcode.notify) + notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer max_parallel_agents: int = 1 @@ -256,6 +259,8 @@ def __init__(self, ollama: Ollama, settings: Settings, cwd: Path, console: Conso self.no_changes = "" # set during a review: why nothing may change (read-only tools only) self.cancel: threading.Event | None = None # set from another thread to stop self.on_tool = None # called with (name, arguments) before each tool runs + self.jobs = Jobs() # background commands (shared with subagents) + self.turn_started = 0.0 # when the running request started (time.monotonic) self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams self.current_call = "" # id of the tool call that's running, so a permission request can name it @@ -746,6 +751,7 @@ def describe_call(self, name: str, args: dict) -> str: return name def run_turn(self, user_text: str) -> None: + self.turn_started = time.monotonic() self.checkpoints.begin_turn(self.session_id, user_text, len(self.messages)) try: self._run_turn(user_text) @@ -760,6 +766,27 @@ def run_turn(self, user_text: str) -> None: Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style) ) + def waiting(self, title: str) -> None: + """lcode waits for the user's answer: tell them, if they've likely walked away (lcode.notify).""" + if self.hooks.for_event("notification"): + self.hooks.notify(f"lcode needs you: {title}", self.cwd) + if ( + self.settings.notify + and self.interactive + and time.monotonic() - self.turn_started >= self.settings.notify_after + ): + notify.desktop("lcode needs you", title) + + def finished(self, request: str, seconds: float) -> None: + """A request is done: after a long one, tell the user.""" + if seconds < self.settings.notify_after: + return + title = sessions.title_from([{"role": "user", "content": request}]) + if self.hooks.for_event("notification"): + self.hooks.notify(f"lcode finished: {title}", self.cwd) + if self.settings.notify and self.interactive: + notify.desktop("lcode is done", f"{title} ({seconds:.0f}s)") + def sandbox_root(self) -> Path | None: """The folder the model is limited to while the sandbox is on (None when it's off).""" if not self.sandbox: diff --git a/src/lcode/api.py b/src/lcode/api.py index 8a8cb9b..2f016db 100644 --- a/src/lcode/api.py +++ b/src/lcode/api.py @@ -192,6 +192,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non prune=cfg["prune"], repo_map=cfg["repo_map"], embed_model=cfg["embed_model"], + notify=cfg["notify"], + notify_after=cfg["notify_after"], ) agent = Agent(ollama, settings, cwd, console=console) agent.interactive = options.interactive @@ -206,8 +208,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non agent.hooks, agent.perms.rules = hooks.load(cwd, trust_project) for problem in agent.hooks.problems: console.print(f"[yellow]Settings: {problem}[/]") - if agent.hooks.for_event("notification"): - agent.perms.on_prompt = lambda title: agent.hooks.notify(f"lcode needs you: {title}", agent.cwd) + agent.perms.on_prompt = agent.waiting # notification hooks and desktop notifications if cfg["lsp"] == "auto": from lcode import lsp from lcode.checkpoints import work_tree_for @@ -233,6 +234,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non def close_agent(agent: Agent) -> None: + stopped = agent.jobs.stop_all() + if stopped: + agent.console.print(f"[dim]Stopped {stopped} background job(s).[/]") if agent.lsp is not None: agent.lsp.close() if agent.mcp: diff --git a/src/lcode/config.py b/src/lcode/config.py index c480a00..9a5623b 100644 --- a/src/lcode/config.py +++ b/src/lcode/config.py @@ -57,6 +57,8 @@ "lsp": ("auto", str, "language servers for code navigation and errors after edits: auto | off"), "prune": (True, bool, "before summarizing a full conversation, first remove old tool output from it"), "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"), + "notify": (True, bool, "desktop notification when a long request is done or waits for you"), + "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"), "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"), } ENV_OVERRIDES = { diff --git a/src/lcode/jobs.py b/src/lcode/jobs.py new file mode 100644 index 0000000..285d438 --- /dev/null +++ b/src/lcode/jobs.py @@ -0,0 +1,165 @@ +"""Background commands: dev servers, watchers and other processes that keep running. + +The model starts one with `bash(command, background=true)` and gets an id back, reads what it +printed since last time with `bash_output(id)` and stops it with `bash_stop(id)`. `/jobs` lists +them. They're all stopped when the session ends. +""" + +from __future__ import annotations + +import contextlib +import os +import signal +import subprocess +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +KEEP = 1_000_000 # characters of a job's output kept in memory +STARTUP_WAIT = 3.0 # seconds to wait for a new job's first output (and quick failures) + + +class JobError(Exception): + pass + + +@dataclass +class Job: + id: str + command: str + proc: subprocess.Popen + kill: Callable[[], None] | None = None # extra cleanup, e.g. the process in the sandbox + started: float = field(default_factory=time.monotonic) + output: str = "" + read: int = 0 # how much of `output` the model has seen + dropped: int = 0 # characters dropped from the front to stay under KEEP + stopped: bool = False + ended: float = 0.0 + lock: threading.Lock = field(default_factory=threading.Lock) + + def pump(self) -> None: + assert self.proc.stdout is not None + for line in self.proc.stdout: + with self.lock: + self.output += line + if len(self.output) > KEEP: + cut = len(self.output) - KEEP + self.output = self.output[cut:] + self.dropped += cut + self.read = max(0, self.read - cut) + self.proc.wait() + self.ended = time.monotonic() + + @property + def running(self) -> bool: + return self.proc.poll() is None + + def status(self) -> str: + if self.running: + return "running" + if self.stopped: + return "stopped" + return f"exited with code {self.proc.returncode}" + + def runtime(self) -> float: + return (self.ended or time.monotonic()) - self.started + + def new_output(self, limit: int) -> str: + with self.lock: + text, self.read = self.output[self.read :], len(self.output) + if len(text) > limit: + text = f"[… {len(text) - limit:,} earlier characters not shown]\n" + text[-limit:] + return text + + +class Jobs: + """The session's background commands (shared with its subagents).""" + + def __init__(self) -> None: + self.items: dict[str, Job] = {} + self.counter = 0 + + def start(self, argv: list[str], cwd: Path, command: str, kill: Callable[[], None] | None = None) -> Job: + proc = subprocess.Popen( + argv, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True, + errors="replace", + start_new_session=True, # its own process group, so stopping it stops its children too + bufsize=1, + ) + self.counter += 1 + job = Job(str(self.counter), command, proc, kill) + self.items[job.id] = job + threading.Thread(target=job.pump, daemon=True).start() + deadline = time.monotonic() + STARTUP_WAIT + while time.monotonic() < deadline and job.running and not job.output: + time.sleep(0.05) + if job.running: + time.sleep(min(0.5, max(0.0, deadline - time.monotonic()))) # a little more of its first output + else: + time.sleep(0.1) # let the pump read the rest + return job + + def get(self, job_id: str) -> Job: + job = self.items.get(str(job_id).strip().lstrip("#")) + if job is None: + known = ", ".join(self.items) or "none" + raise JobError(f"there's no background job {job_id} (jobs: {known})") + return job + + def stop(self, job_id: str) -> Job: + job = self.get(job_id) + if job.running: + job.stopped = True + terminate(job) + return job + + def stop_all(self) -> int: + running = [j for j in self.items.values() if j.running] + for job in running: + job.stopped = True + terminate(job) + return len(running) + + def running(self) -> list[Job]: + return [j for j in self.items.values() if j.running] + + +def stop_leftovers(group: int) -> bool: + """Stop what a finished command left running in its process group; whether there was anything.""" + try: + os.killpg(group, 0) # raises when nothing is left + except (ProcessLookupError, PermissionError): + return False + for sig in (signal.SIGTERM, signal.SIGKILL): + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(group, sig) + time.sleep(0.3) + return True + + +def terminate(job: Job, grace: float = 3.0) -> None: + """SIGTERM the job's process group, then SIGKILL whatever is left.""" + if job.kill is not None: + with contextlib.suppress(Exception): + job.kill() + for sig in (signal.SIGTERM, signal.SIGKILL): + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(job.proc.pid, sig) + try: + job.proc.wait(timeout=grace) + return + except subprocess.TimeoutExpired: + continue + + +def describe(job: Job) -> str: + minutes, seconds = divmod(int(job.runtime()), 60) + took = f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s" + return f"job {job.id} ({job.status()}, {took}): {job.command}" diff --git a/src/lcode/notify.py b/src/lcode/notify.py new file mode 100644 index 0000000..8d6a456 --- /dev/null +++ b/src/lcode/notify.py @@ -0,0 +1,43 @@ +"""Desktop notifications: when a long request is done, or lcode waits for an answer during one. + +Local models are slow, so people switch to something else while a request runs. lcode shows a +notification with `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell +where neither is available. +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import sys +import threading + + +def command(title: str, message: str) -> list[str] | None: + """The command that shows a notification on this machine, or None.""" + if platform.system() == "Darwin" and shutil.which("osascript"): + quoted = message.replace("\\", "\\\\").replace('"', '\\"') + heading = title.replace("\\", "\\\\").replace('"', '\\"') + return ["osascript", "-e", f'display notification "{quoted}" with title "{heading}"'] + if shutil.which("notify-send"): + return ["notify-send", "--app-name=lcode", title, message] + return None + + +def desktop(title: str, message: str) -> None: + """Show the notification without waiting for it; the terminal bell if there's no way to.""" + argv = command(title, message[:200]) + if argv is None: + sys.stderr.write("\a") + sys.stderr.flush() + return + + def run() -> None: + try: + subprocess.run(argv, capture_output=True, timeout=10) + except (OSError, subprocess.SubprocessError): + sys.stderr.write("\a") + sys.stderr.flush() + + threading.Thread(target=run, daemon=True).start() diff --git a/src/lcode/planning.py b/src/lcode/planning.py index 5e1b517..f86d6b4 100644 --- a/src/lcode/planning.py +++ b/src/lcode/planning.py @@ -32,7 +32,7 @@ PLAN_MODE_TOOLS = frozenset( { "read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write", - "agent", "memory", "present_plan", "mcp_find_tools", "skill", "lsp", "repo_map", "search_code", + "agent", "memory", "present_plan", "mcp_find_tools", "skill", "lsp", "repo_map", "search_code", "bash_output", } ) # fmt: skip @@ -103,6 +103,8 @@ def present(agent: Agent, title: str, plan: str) -> str: if not agent.interactive: return "The plan was shown to the user, who will review it later. Stop here and don't change anything." c.print(CHOICES) + if agent.perms.on_prompt: + agent.perms.on_prompt(f"Plan: {title}") try: answer = input(" > ").strip() except EOFError: diff --git a/src/lcode/repl.py b/src/lcode/repl.py index 4f2d2f3..5b5b7a2 100644 --- a/src/lcode/repl.py +++ b/src/lcode/repl.py @@ -62,6 +62,7 @@ "/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)", "/cd": "Change the working directory", "/todos": "Show the current todo list", + "/jobs": "Background commands the model started: /jobs, /jobs stop ", "/commit": "Commit the changes with a message in the repository's style, after you approve it", "/review": "Review the uncommitted changes, or the branch against a base: /review [base]", "/pr": "Push the branch and open a GitHub pull request, after you approve it: /pr [base] [notes]", @@ -776,7 +777,28 @@ def web_status(agent: Agent) -> str: return f"{agent.settings.web} · {search}" -LONG_REQUEST = 30 # seconds: a request this long sends a notification when it's done (notification hooks) +def jobs_command(agent: Agent, arg: str) -> None: + """List the background commands, or stop one: /jobs stop .""" + from lcode.jobs import JobError, describe + + c = agent.console + word, _, job_id = arg.partition(" ") + if word == "stop": + try: + job = agent.jobs.stop(job_id or "?") + except JobError as e: + c.print(f"[red]{escape(str(e))}[/]") + return + c.print(f"Stopped {escape(describe(job))}") + return + if not agent.jobs.items: + c.print("No background commands. The model starts them with bash(background=true), e.g. a dev server.") + return + for job in agent.jobs.items.values(): + style = "green" if job.running else "dim" + c.print(f"[{style}]{escape(describe(job))}[/]") + if agent.jobs.running(): + c.print("[dim]/jobs stop stops one; they all stop when the session ends.[/]") def git_command(agent: Agent, cmd: str, arg: str) -> None: @@ -810,8 +832,7 @@ def run_safely(agent: Agent, text: str) -> None: agent.console.print(f"[red]{e}[/]") finally: agent.save() - if time.monotonic() - started > LONG_REQUEST: - agent.hooks.notify(f"lcode finished: {sessions.title_from([{'role': 'user', 'content': text}])}", agent.cwd) + agent.finished(text, time.monotonic() - started) def session_start(agent: Agent) -> None: @@ -931,6 +952,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool: c.print(f"[red]Not a directory: {p}[/]") elif cmd == "/todos": agent.tools.show_todos() + elif cmd == "/jobs": + jobs_command(agent, arg) elif cmd == "/init": run_safely(agent, INIT_PROMPT) elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands: diff --git a/src/lcode/subagents.py b/src/lcode/subagents.py index 05435e6..556b492 100644 --- a/src/lcode/subagents.py +++ b/src/lcode/subagents.py @@ -463,6 +463,7 @@ def __init__(self, parent: Agent, kind: AgentType, task: str, label: str, isolat child = Agent(parent.ollama, settings, cwd, console=Console(file=io.StringIO(), width=120)) child.perms = self.relay # type: ignore[assignment] child.sandbox = parent.sandbox + child.jobs = parent.jobs # its background commands belong to the session if not self.worktree: child.checkpoints = parent.checkpoints # changes in the shared tree are part of the running request child.mcp = None diff --git a/src/lcode/tools.py b/src/lcode/tools.py index 9005efd..dc22c88 100644 --- a/src/lcode/tools.py +++ b/src/lcode/tools.py @@ -25,6 +25,7 @@ from lcode import web from lcode.context import shorten +from lcode.jobs import JobError, describe, stop_leftovers from lcode.permissions import bash_key, is_read_only from lcode.planning import BLOCKED, PLAN_MODE_TOOLS from lcode.sandbox import SandboxError @@ -38,6 +39,7 @@ r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host" ) MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call +LEFTOVER_GRACE = 0.5 # seconds to wait for output after a command ends, before stopping what it left running IGNORE_DIRS = { ".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".idea", ".vscode", "dist", "build", ".next", "target", ".cache", ".gradle", @@ -119,13 +121,28 @@ def _fn(name: str, description: str, properties: dict, required: list[str]) -> d "bash", "Run a shell command with bash in the working directory and return its output and exit code. The " "working directory persists between calls (cd works). Use it to run scripts and tests, use git, install " - "packages, etc. Avoid interactive commands.", + "packages, etc. Avoid interactive commands. For a process that keeps running (a dev server, a watcher), " + "set background=true and give the plain command, without & or redirecting its output: lcode keeps it " + "running and keeps its output, you get a job id back and can carry on.", { "command": {"type": "string"}, "timeout": {"type": "integer", "description": "Seconds before the command is killed (default 180)"}, + "background": { + "type": "boolean", + "description": "Keep it running in the background; read its output with bash_output and stop it " + "with bash_stop (not with kill)", + }, }, ["command"], ), + _fn( + "bash_output", + "Read what a background command (bash with background=true) printed since you last read it, and whether " + "it's still running.", + {"id": {"type": "string", "description": "The job id bash returned"}}, + ["id"], + ), + _fn("bash_stop", "Stop a background command.", {"id": {"type": "string"}}, ["id"]), _fn( "todo_write", "Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one " @@ -792,7 +809,7 @@ def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: return f"Edited {self.rel(p)} ({count} replacement(s)). Result:\n{snippet}" + problems # -- shell - def t_bash(self, command: str, timeout: int = 180) -> str: + def t_bash(self, command: str, timeout: int = 180, background: bool = False) -> str: sandbox = self.agent.sandbox if self.agent.planning() and not is_read_only(command): raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).") @@ -814,7 +831,9 @@ def t_bash(self, command: str, timeout: int = 180) -> str: return feedback if not is_read_only(command): self.agent.checkpoint() - self.console.print(Text(f" $ {command}", style="bold cyan")) + self.console.print(Text(f" $ {command}" + (" (in the background)" if background else ""), style="bold cyan")) + if background is True or str(background).lower() == "true": + return self._background(command) marker = f"__lcode_cwd_{secrets.token_hex(8)}__" script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n" token = "" @@ -855,6 +874,8 @@ def stop() -> None: out: list[str] = [] shown, status, recorded = 0, "", "" deadline = time.time() + int(timeout or 180) + finished = 0.0 # when the command itself ended (its marker arrived) + held_open = False # something it started still holds the output open try: while True: if self.agent.cancel is not None and self.agent.cancel.is_set(): @@ -862,6 +883,9 @@ def stop() -> None: try: line = lines.get(timeout=0.2) except queue.Empty: + if finished and time.time() - finished > LEFTOVER_GRACE: + held_open = True + break if time.time() > deadline: stop() status = f"\n[Command timed out after {timeout}s and was killed]" @@ -871,6 +895,7 @@ def stop() -> None: break if line.startswith(marker): recorded = line[len(marker) :].strip() + finished = time.time() continue out.append(line) if shown <= 40: @@ -881,6 +906,13 @@ def stop() -> None: stop() raise code = proc.wait() + if held_open and sandbox and token: + sandbox.kill(token) # what it left running in the container + if (not sandbox and stop_leftovers(proc.pid)) or held_open: + status += ( + "\n[lcode stopped what this command left running (started with &). To keep a process running, " + "start it with background=true: then bash_output reads its output and bash_stop stops it]" + ) if out and out[-1] == "\n": out.pop() # the blank line printed before the marker if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd: @@ -933,6 +965,51 @@ def t_web_fetch(self, url: str, max_chars: int = 20000) -> str: raise ToolError(str(e)) from e return web.format_page(url, title, text, max(1000, min(int(max_chars or 20000), MAX_TOOL_OUTPUT))) + def _background(self, command: str) -> str: + sandbox, kill = self.agent.sandbox, None + if sandbox: + try: + with self.console.status("Starting the sandbox…"): + sandbox.ensure(self.agent.cwd) + except SandboxError as e: + return f"Error: the sandbox can't start, so the command didn't run: {e}" + argv, token = sandbox.exec_argv(self.agent.cwd, command) + kill = lambda: sandbox.kill(token) # noqa: E731 + else: + argv = ["bash", "-c", command] + job = self.agent.jobs.start(argv, self.agent.cwd, command, kill) + first = job.new_output(MAX_TOOL_OUTPUT // 3).rstrip() + for line in first.splitlines()[:10]: + self.console.print(Text(" " + line[:300], style="dim")) + if not job.running: + return ( + f"The command ended right away ({job.status()}), so it isn't running in the background:\n" + f"{first or '(no output)'}" + ) + self.console.print(Text(f" ⎿ background job {job.id}, /jobs to see it", style="dim")) + return ( + f"Started background job {job.id}: it keeps running while you work. Read its new output with " + f'bash_output(id="{job.id}") and stop it with bash_stop(id="{job.id}") when it\'s no longer needed.\n' + f"Output so far:\n{first or '(none yet)'}" + ) + + def t_bash_output(self, id: str) -> str: + try: + job = self.agent.jobs.get(id) + except JobError as e: + raise ToolError(str(e)) from e + text = job.new_output(MAX_TOOL_OUTPUT).rstrip() + return f"[{describe(job)}]\n{text or '(no new output)'}" + + def t_bash_stop(self, id: str) -> str: + try: + job = self.agent.jobs.stop(id) + except JobError as e: + raise ToolError(str(e)) from e + self.console.print(Text(f" ⎿ {describe(job)}", style="dim")) + last = job.new_output(4000).rstrip() + return f"[{describe(job)}]" + (f"\nIts last output:\n{last}" if last else "") + # -- planning def t_todo_write(self, todos: list | str) -> str: if isinstance(todos, str): diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..5b992c0 --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,172 @@ +import shlex +import sys +import time +from pathlib import Path + +import pytest + +from conftest import call, output, reply +from lcode import api, jobs, notify, planning +from lcode.hardware import Hardware +from lcode.repl import handle_command + +HW = Hardware("linux", "x", 31, "GPU", 12) +TICKER = f"{shlex.quote(sys.executable)} -u -c " + shlex.quote( + "import time\nfor i in range(600):\n print('tick', i)\n time.sleep(0.05)" +) + + +def gone(pid: int) -> bool: + """The process ended (or is a zombie waiting for its parent).""" + status = Path(f"/proc/{pid}/status") # Linux; elsewhere there's nothing to check + return not status.exists() or "zombie" in status.read_text().lower() + + +def wait_for(condition, seconds=5.0): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if condition(): + return True + time.sleep(0.05) + return False + + +# ----------------------------------------------------------------------------- background commands + + +def test_a_job_runs_until_stopped_with_its_children(tmp_path): + pool = jobs.Jobs() + job = pool.start(["bash", "-c", f"sleep 60 & echo $! > child.pid; {TICKER}"], tmp_path, "ticker") + assert job.running and job.id == "1" and "tick 0" in job.new_output(10_000) + assert wait_for(lambda: "tick" in job.output[job.read :]) + newer = job.new_output(10_000) + assert newer and "tick 0\n" not in newer # only what's new + child = int((tmp_path / "child.pid").read_text()) + stopped = pool.stop("1") + assert stopped.status() == "stopped" and not stopped.running + assert wait_for(lambda: gone(child)) + with pytest.raises(jobs.JobError, match=r"there's no background job 9 \(jobs: 1\)"): + pool.get("9") + + +def test_output_is_capped(tmp_path, monkeypatch): + monkeypatch.setattr(jobs, "KEEP", 2000) + pool = jobs.Jobs() + job = pool.start(["bash", "-c", "for i in $(seq 1 2000); do echo line $i; done"], tmp_path, "lines") + assert wait_for(lambda: not job.running and job.ended) + assert len(job.output) <= 2000 and job.dropped > 0 and job.output.rstrip().endswith("line 2000") + text = job.new_output(100) + assert text.startswith("[… ") and text.rstrip().endswith("line 2000") and job.status() == "exited with code 0" + + +def test_the_model_runs_reads_and_stops_a_background_command(make_agent, repo): + agent = make_agent( + [ + reply(tool_calls=[call("bash", command=TICKER, background=True)]), + reply(tool_calls=[call("bash_output", id="1")]), + reply(tool_calls=[call("bash_stop", id="1")]), + reply(tool_calls=[call("bash", command="echo done", background=True)]), + reply("All done."), + ] + ) + agent.run_turn("start the ticker, check it, stop it") + results = [m["content"] for m in agent.messages if m["role"] == "tool"] + assert results[0].startswith("Started background job 1: it keeps running") and "tick 0" in results[0] + assert results[1].startswith("[job 1 (running, ") and "tick" in results[1] + assert results[2].startswith("[job 1 (stopped, ") + assert results[3].startswith("The command ended right away (exited with code 0)") and "done" in results[3] + assert "(in the background)" in output(agent) + handle_command(agent, "/jobs", HW) + assert "job 1 (stopped" in output(agent) and "job 2 (exited with code 0" in output(agent) + + +def test_jobs_stop_with_the_session_and_from_jobs(make_agent, repo): + agent = make_agent([reply(tool_calls=[call("bash", command=TICKER, background=True)]), reply("ok")]) + agent.run_turn("start it") + handle_command(agent, "/jobs", HW) + assert "job 1 (running" in output(agent) and "/jobs stop " in output(agent) + handle_command(agent, "/jobs stop 1", HW) + assert "Stopped job 1 (stopped" in output(agent) and not agent.jobs.running() + agent.jobs.start(["bash", "-c", TICKER], repo, "again") + api.close_agent(agent) + assert not agent.jobs.running() and "Stopped 1 background job(s)." in output(agent) + + +def test_what_a_command_leaves_running_is_stopped(make_agent, repo): + agent = make_agent( + [ + reply(tool_calls=[call("bash", command=f"{TICKER} > ticks.log &\necho started")]), + reply(tool_calls=[call("bash", command="sleep 0.2 & sleep 0.1 & wait; echo both done")]), + reply("ok"), + ] + ) + agent.run_turn("start a ticker") + first, second = [m["content"] for m in agent.messages if m["role"] == "tool"] + assert "started" in first and "lcode stopped what this command left running" in first + assert "background=true" in first + size = (repo / "ticks.log").stat().st_size + time.sleep(0.3) + assert (repo / "ticks.log").stat().st_size == size # it really stopped + assert "both done" in second and "lcode stopped" not in second + + +def test_plan_mode_allows_reading_but_not_starting(make_agent, repo): + agent = make_agent( + [ + reply(tool_calls=[call("bash", command="python -m http.server", background=True)]), + reply(tool_calls=[call("bash_output", id="1")]), + reply("ok"), + ], + mode="plan", + ) + agent.run_turn("start a server") + results = [m["content"] for m in agent.messages if m["role"] == "tool"] + assert "Plan mode is on" in results[0] and "there's no background job 1" in results[1] + + +# ----------------------------------------------------------------------------- notifications + + +def test_notification_commands(monkeypatch, capsys): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}") + assert notify.command("lcode", "done") == ["notify-send", "--app-name=lcode", "lcode", "done"] + monkeypatch.setattr("platform.system", lambda: "Darwin") + assert notify.command("lcode", 'say "hi"') == [ + "osascript", "-e", 'display notification "say \\"hi\\"" with title "lcode"'] # fmt: skip + monkeypatch.setattr("shutil.which", lambda name: None) + assert notify.command("lcode", "done") is None + notify.desktop("lcode", "done") + assert capsys.readouterr().err == "\a" # the terminal bell instead + + +def test_long_requests_and_waits_notify(make_agent, monkeypatch): + shown = [] + monkeypatch.setattr(notify, "desktop", lambda title, message: shown.append((title, message))) + agent = make_agent(notify=True, notify_after=30) + agent.interactive = True + agent.finished("fix the parser", 12) + assert shown == [] + agent.finished("fix the parser", 95) + assert shown == [("lcode is done", "fix the parser (95s)")] + agent.turn_started = time.monotonic() + agent.waiting("Run command") + assert len(shown) == 1 # the user is probably still watching + agent.turn_started = time.monotonic() - 60 + agent.waiting("Run command") + assert shown[-1] == ("lcode needs you", "Run command") + agent.interactive = False # lcode -p, scripts, editors + agent.finished("fix the parser", 95) + agent.settings.notify, agent.interactive = False, True + agent.finished("fix the parser", 95) + assert len(shown) == 2 + + +def test_a_plan_waiting_for_approval_notifies(make_agent, monkeypatch): + seen = [] + agent = make_agent(mode="plan") + agent.interactive = True + agent.perms.on_prompt = seen.append + monkeypatch.setattr("builtins.input", lambda prompt="": "n") + planning.present(agent, "Rename", "1. Rename it") + assert seen == ["Plan: Rename"]