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:
ProcessResultis asubprocess.CompletedProcess, andcheck=Trueraisessubprocess.CalledProcessError.ExecutableNotFoundis aFileNotFoundError.ProcessTimeoutis both asubprocess.TimeoutExpiredand aTimeoutError, 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:
- A console is already attached: nothing to do.
- Otherwise, on Windows 11 24H2 and Windows Server 2025 and later, the process
calls
AllocConsoleWithOptionswithALLOC_CONSOLE_MODE_NO_WINDOW. - Otherwise, it relaunches under
python.exewithCREATE_NO_WINDOW, holds the relaunched host in a kill-on-close job, waits, and exits with its exit status. - 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_WINDOWruns a console application without a console window, and is ignored for a GUI application or together withCREATE_NEW_CONSOLEorDETACHED_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_CLOSEandJOB_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 startand the statusRuntime:line.architecture/resilience: the deadlines tool runs honour.architecture/hot-path-discipline: why external calls need a deadline.