Skip to content

Process Execution

How work-buddy starts operating-system processes. One package, work_buddy.process, owns every launch through four intents (tool run, worker, host, visible terminal), fixes the runtime context of work-buddy's own processes through host roles (console, interpreter image, identity), records that context for status and health, and an architecture test keeps every launch inside the package.

Details

work-buddy starts operating-system processes in one place: the work_buddy.process package. Callers say what they are launching, and the package applies the operating-system policy for that intent, so no call site decides window, console, stdin, environment, timeout or cleanup behaviour on its own. The package also owns the runtime context of work-buddy's own long-running processes, which on Windows decides whether the console programs they start stay invisible.

The four intents

Intent Function Used for Windows POSIX Timeout
Tool run run_tool(argv, *, timeout, ...) Captured, awaited programs: git, gh, PowerShell, netstat, tailscale, lms, pandoc, python -m runners This process's console, or CREATE_NO_WINDOW when it has none Own session, except in a terminal's foreground job Required, clamped to the caller's resilience deadline
Worker spawn_worker(argv, ...) Children the caller owns and does not await: agent workers, the document kernel, the dashboard build, OS openers CREATE_NO_WINDOW Own session on request (new_session=True) None: the owner ends it with terminate_tree
Host start_host(role, module, ...) work-buddy's own Python processes: the sidecar, its services, the messaging auto-start, the tray By role (below) Own session for detached starts None
Visible terminal open_visible_terminal(argv, ...) Remote-session terminals, and nothing else CREATE_NEW_CONSOLE The platform terminal emulator None

A tool run shares this process's console when it has one: that console has no window (every background host establishes one) or is a terminal the user already has open, so sharing it opens nothing, and it saves starting a console host for every call. Only a process with no console, such as the tray or anything under pythonw.exe, gives the tool run CREATE_NO_WINDOW. Console programs started as workers or hosts get a console of their own with no window, so they never depend on a terminal staying open. A GUI program, such as the tray, has no console.

Tool runs never go through a shell and never wait on a prompt. stdin is closed unless input= is given, and the environment gets a non-interactive overlay: GIT_TERMINAL_PROMPT=0, GCM_INTERACTIVE=never, GIT_OPTIONAL_LOCKS=0 and GH_PROMPT_DISABLED=1. env=, when given, is the child's complete environment, as with subprocess.run, and the overlay still applies on top of it. Output decodes exactly as subprocess.run(..., text=True) does unless text=False, encoding= or errors= say otherwise. powershell_argv(script) builds a PowerShell argv that always carries -NoProfile -NonInteractive, and encode=True passes the script as -EncodedCommand.

A tool run without a finite, positive timeout is rejected. When the caller holds a work_buddy.resilience deadline, the shorter of the two applies, and a deadline that has already passed raises before anything starts. On timeout the whole process tree is ended, not only the direct child. The exception is a POSIX tool run started from a terminal's foreground job, which shares the terminal's process group, so only the direct child is killed.

Results and errors stay compatible with existing handlers:

  • ProcessResult is a subprocess.CompletedProcess, and check=True raises subprocess.CalledProcessError.
  • ExecutableNotFound is a FileNotFoundError.
  • ProcessTimeout is both a subprocess.TimeoutExpired and a TimeoutError, so the resilience framework classifies it as a timeout.

work_buddy.process.tree holds the process-tree primitives. terminate_tree(target) force-ends a process handle or a pid and what it started: taskkill /F /T on Windows, and on POSIX a SIGKILL to the target's process group when it leads its own group, otherwise to the target alone. So on POSIX a worker's children are ended only when it was started with new_session=True. The caller's resilience deadline never stops or shortens the kill, because cleanup usually runs after that deadline has passed. kill_process_on_port frees a port and reports truthfully whether it is free, refusing rather than guessing when the owner lookup fails. find_child_pids and the kill-on-close Job Object helpers create_kill_on_close_job and assign_process_to_job complete the set.

work_buddy.process.liveness holds the pid checks. is_process_alive(pid) says whether a pid names a running process, and process_start_token(pid) fingerprints one lifetime of it, so code that recorded a process (a PID file, a lock, a dispatched run) can tell it from a later process that reused the number.

Host roles and the console model

A process with no console that starts a console program without CREATE_NO_WINDOW makes Windows create a new console, with a visible window, for that program. Library code does exactly that. So work-buddy's background hosts run with a console of their own that has no window: their console children inherit it and stay invisible, whoever launches them. GUI hosts have no console and launch every child through the policy instead.

Role Interpreter Windows image Windows console Identity
SIDECAR The launcher's own (sys.executable) python.exe, or pythonw.exe when the logon task starts it Its own, with no window A fresh sidecar- session id, never inherited
SERVICE The pinned child interpreter python.exe Hidden, from CREATE_NO_WINDOW The sidecar's, inherited
TRAY The pinned child interpreter pythonw.exe None, by design The CLI's (wbuddy-cli), never inherited

start_host applies the role: it selects the role's image from the same directory as the interpreter (host.sibling_image, which never changes which environment runs), builds the role's environment, and starts the host with CREATE_NO_WINDOW. Given log_path=, it appends the host's stdout and stderr to that file after a start banner, as the sidecar does for each service and the messaging auto-start does for itself. Without one, the host's stdio is discarded.

establish_host_context(role) is the first statement of work_buddy/sidecar/__main__.py, so it runs under every launcher, including the logon task, or a hand-made shortcut, that starts pythonw.exe, which has no console. Such a daemon keeps the pythonw.exe image and gets its console in place. On Windows, for a background role:

  1. A console is already attached: nothing to do.
  2. Otherwise, on Windows 11 24H2 and Windows Server 2025 and later, the process calls AllocConsoleWithOptions with ALLOC_CONSOLE_MODE_NO_WINDOW.
  3. Otherwise, it relaunches under python.exe with CREATE_NO_WINDOW, holds the relaunched host in a kill-on-close job, waits, and exits with its exit status.
  4. If both fail, it logs an error and carries on without a console, and status reports it. A host never refuses to start over its console.

Then it assigns the role's identity. wbuddy start --foreground calls it with foreground=True, which keeps the terminal's console but still gives the daemon its own sidecar identity, so a foreground daemon uses the sidecar's consent database like every other start path. start_host gives the tray its identity in its environment, because importing the tray package may already need one, and the tray's entry point claims it again.

The interpreter pin (sidecar.python_executable) keeps its semantics. resolve_child_python, in work_buddy.process.host, returns the pin when it names an existing file, else sys.executable, and warns when they differ. A pin that names pythonw.exe still yields python.exe for services, from the same directory. build_child_env, also in process.host, gives Python children PYTHONUTF8=1 and OPENBLAS_NUM_THREADS=1, each with setdefault.

The runtime record

At boot the daemon stores describe_host_context() in the host field of the sidecar state file (sidecar.state.HostRecord): its executable and image, its console (allocated, hidden, attached, none or not_applicable) and the mechanism behind it (inherited, allocate_api, relaunch, foreground, none or not_applicable), the interpreter and image its services run on, the pin, and whether the pin points outside the project. wbuddy status prints it as one line. For a daemon that wbuddy start started:

Runtime: python.exe, hidden console | services: C:\work-buddy\.venv\Scripts\python.exe (sidecar interpreter)

A daemon the logon task started shows pythonw.exe, console allocated without window instead. Status warns when the daemon has no console on Windows, when services would run on pythonw.exe, and when the pin points outside the project. The sidecar health component's step "Sidecar starts programs without opening windows" reads the same record and fails on the two kinds of drift that let console programs open windows.

Testing

ProcessRunner is the one test seam. RecordingRunner records every ResolvedLaunch (argv, cwd, environment, stdio, creation flags, session, timeout, input) and answers from scripted results: an argv-prefix rule, a predicate, or a responder callable, with the most recently added match winning. Spawned workers come back as FakeProcess handles. The recording_runner fixture in tests/conftest.py installs one for a test, and use_runner installs one process-wide for a block, including launches from threads the code under test starts. Code that takes an injected runner, such as the agent-execution providers, passes it on as runner=.

Patching subprocess.run at a call site intercepts nothing, because call sites no longer call it. Patching subprocess.Popen still reaches the real runner, but it skips the launch policy, so the test no longer checks what production does. Retarget such a test to the recording runner.

The boundary rule

tests/unit/architecture/test_process_boundary.py walks work_buddy/ and fails on any launch outside work_buddy/process/: a call to, or a value reference of, subprocess.run, Popen, call, check_call, check_output, getoutput or getstatusoutput, os.system, os.popen, os.startfile, os.spawn*, os.exec* or os.posix_spawn*, or asyncio.create_subprocess_exec or create_subprocess_shell. It also fails on a creationflags= argument or a reference to CREATE_NO_WINDOW, CREATE_NEW_CONSOLE or DETACHED_PROCESS. The subprocess exception types, CompletedProcess, DEVNULL, PIPE, STDOUT, list2cmdline, and any of these names inside a type annotation stay allowed everywhere. The test also checks that the package imports only the standard library at module level.

An exemption goes in the test's EXEMPT dictionary with a written reason, and only for a launch the package cannot express. Stale exemptions fail the test. PTY sessions (through winpty or ptyprocess, windowless by construction) and webbrowser.open are outside the rule's reach and need no exemption.

Windows sources

  • Process Creation Flags: CREATE_NO_WINDOW runs a console application without a console window, and is ignored for a GUI application or together with CREATE_NEW_CONSOLE or DETACHED_PROCESS.
  • Creation of a Console: a console process inherits its parent's console by default, and a process with no console gets a new one.
  • AllocConsoleWithOptions: Windows 11 24H2 (build 26100) and Windows Server 2025 and later.
  • JOBOBJECT_BASIC_LIMIT_INFORMATION: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK.

See also

  • services/sidecar: the daemon, its services, takeover, and the Job Object that reaps services on a hard kill.
  • services/tray: the tray, a GUI host.
  • operations/wb-cli: wbuddy start and the status Runtime: line.
  • architecture/resilience: the deadlines tool runs honour.
  • architecture/hot-path-discipline: why external calls need a deadline.