Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

42 changes: 38 additions & 4 deletions docs/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ same run-discovery mechanism.
- [Animation and remote sessions](#animation-and-remote-sessions)
- [Screens](#screens)
- [Key bindings](#key-bindings)
- [Launch directory](#launch-directory)
- [Status vocabulary](#status-vocabulary)
- [Gates: display vs. resolve](#gates-display-vs-resolve)
- [Division of labor: TUI vs. dashboard](#division-of-labor-tui-vs-dashboard)
Expand Down Expand Up @@ -213,9 +214,11 @@ stack rather than each managing its own navigation state.
- **New run** (`n`) — enter a file path or registry reference, resolve it,
and fill in a form generated from the workflow's declared `input:`
block (required fields marked, defaults pre-filled, descriptions shown).
Submitting shells out to `conductor run --web-bg` via the same
`launch_background()` the CLI itself uses, so a launched run outlives
the TUI rather than dying with it. Once the launch succeeds, this screen pops
A relative reference resolves against the current
[launch directory](#launch-directory) (`ctrl+d` to change it, from either
this screen or Runs). Submitting shells out to `conductor run --web-bg`
via the same `launch_background()` the CLI itself uses, so a launched run
outlives the TUI rather than dying with it. Once the launch succeeds, this screen pops
back to Runs, where the new run appears on the next poll tick; the TUI
never tracks a launched run's lifecycle beyond that (**viewer, not
supervisor**).
Expand Down Expand Up @@ -264,12 +267,17 @@ Bindings shown are the Runs (home) screen's; each drill-down screen binds
| `k` | Kill the selected run (confirms first) |
| `K` | Kill every displayed run (confirms once) |
| `g` | Resolve the selected run's open gate (see [below](#gates-display-vs-resolve)) |
| `n` | New run |
| `d` | Change the [launch directory](#launch-directory) |
| `p` | Providers |
| `r` | Registries |
| `n` | New run |
| `h` | History |
| `q` | Quit |

The Runs footer also hides the docked `^p palette` key to make room for the
above (`ctrl+p` still opens the command palette — only the footer key is
hidden, not the palette itself).

Screens with a row-scoped `enter` advertise it in their own footer:

| Screen | `enter` |
Expand Down Expand Up @@ -301,6 +309,32 @@ run discards in-flight progress unless periodic checkpoints are enabled
for it (the same warning `conductor stop`'s own confirmation shows — one
policy, two presentations, sharing the same underlying implementation).

## Launch directory

`d` (Runs) / `ctrl+d` (New run) opens a directory picker — type a path, or
browse a tree rooted at the current directory's parent (so a sibling
checkout is one keypress away) — and sets the TUI's **launch directory**
for the rest of this `conductor fleet` session.

The launch directory affects two things:

- A **relative** workflow reference on the New Run screen resolves against
it, not against wherever `conductor fleet` happened to be started.
- A launched run's detached child inherits it as its working directory,
which is what its Directory column shows and what a `type: script` step
without an explicit `working_dir:` defaults to.

It does **not** affect `runtime.working_dir` / `agent.working_dir` or a
sub-workflow's own workflow-file reference — those always resolve against
the *workflow file's* directory, unrelated to where the TUI itself was
launched from or later pointed at. It is also not a filter: the Runs and
History screens always show the whole fleet, regardless of the current
launch directory.

**Process-lifetime only** — there is no `config.toml` key and no state file
behind it. It starts at `conductor fleet`'s own working directory every
time, and resets the moment this process exits.

## Status vocabulary

Status is a small explicit state machine, not a boolean:
Expand Down
72 changes: 61 additions & 11 deletions src/conductor/cli/bg_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ def _spawn_detached_windows(
stdout: Any,
stderr: Any,
stdin: Any,
cwd: Path | None = None,
) -> _WindowsDetachedProcess:
"""Windows ``_spawn_detached``: suspend, job-assign, then resume (issue #447).

Expand All @@ -547,6 +548,8 @@ def _spawn_detached_windows(
stdout: ``subprocess.DEVNULL`` or an open file-like object.
stderr: ``subprocess.DEVNULL`` or an open file-like object.
stdin: ``subprocess.DEVNULL`` or an open file-like object.
cwd: Working directory for the child, or ``None`` to inherit the
parent's (issue #477).

Returns:
A :class:`_WindowsDetachedProcess` wrapping the running child,
Expand All @@ -560,6 +563,7 @@ def _spawn_detached_windows(
_resolve_stdio_handle(stdout, for_write=True),
_resolve_stdio_handle(stderr, for_write=True),
]
current_directory = str(cwd) if cwd is not None else None
try:
si = _StartupInfo()
si.dwFlags |= _winapi.STARTF_USESTDHANDLES
Expand All @@ -570,7 +574,7 @@ def _spawn_detached_windows(
creationflags = _CREATE_NEW_PROCESS_GROUP | _CREATE_BREAKAWAY_FROM_JOB | _CREATE_SUSPENDED
try:
hp, ht, pid, _tid = _winapi.CreateProcess(
None, cmd_line, None, None, True, creationflags, env, None, si
None, cmd_line, None, None, True, creationflags, env, current_directory, si
)
except OSError as exc:
if not _is_breakaway_denied(exc):
Expand All @@ -583,7 +587,7 @@ def _spawn_detached_windows(
)
creationflags &= ~_CREATE_BREAKAWAY_FROM_JOB
hp, ht, pid, _tid = _winapi.CreateProcess(
None, cmd_line, None, None, True, creationflags, env, None, si
None, cmd_line, None, None, True, creationflags, env, current_directory, si
)
finally:
for handle in handles:
Expand Down Expand Up @@ -616,6 +620,7 @@ def _spawn_detached_posix(
stdout: Any,
stderr: Any,
stdin: Any,
cwd: Path | None = None,
) -> subprocess.Popen[Any]:
"""POSIX ``_spawn_detached`` -- unchanged ``subprocess.Popen`` call.

Expand All @@ -626,8 +631,19 @@ def _spawn_detached_posix(
child. ``proc.pid`` is recorded in :data:`_SPAWNED_GROUP_LEADERS` so
that call is only ever made against a pid this module spawned as a
group leader.

``cwd`` (issue #477) is passed straight through to ``Popen`` -- the
child's own ``os.getcwd()`` is what ``engine/workflow.py`` stamps as
``system.cwd``, so this is the only seam that needs to change for the
Directory column and a ``type: script`` step's default cwd to follow.
"""
base: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "stdin": stdin, "env": env}
base: dict[str, Any] = {
"stdout": stdout,
"stderr": stderr,
"stdin": stdin,
"env": env,
"cwd": cwd,
}
proc = subprocess.Popen(cmd, **base, start_new_session=True) # noqa: S603
_SPAWNED_GROUP_LEADERS.add(proc.pid)
return proc
Expand All @@ -640,6 +656,7 @@ def _spawn_detached(
stdout: Any = subprocess.DEVNULL,
stderr: Any = subprocess.DEVNULL,
stdin: Any = subprocess.DEVNULL,
cwd: Path | None = None,
) -> _DetachedChild:
"""Launch a fully-detached child process for ``--web-bg`` mode.

Expand All @@ -661,6 +678,8 @@ def _spawn_detached(
stderr: Popen ``stderr`` argument; defaults to ``DEVNULL``. Pass
an open file handle to capture the child's stderr.
stdin: Popen ``stdin`` argument; defaults to ``DEVNULL``.
cwd: Working directory for the child, or ``None`` to inherit the
parent's (issue #477 -- the Fleet Manager TUI's ``launch_dir``).

Returns:
The running detached child -- a :class:`subprocess.Popen` on
Expand All @@ -672,8 +691,8 @@ def _spawn_detached(
missing executable). Callers wrap this in a ``RuntimeError``.
"""
if sys.platform == "win32":
return _spawn_detached_windows(cmd, env, stdout=stdout, stderr=stderr, stdin=stdin)
return _spawn_detached_posix(cmd, env, stdout=stdout, stderr=stderr, stdin=stdin)
return _spawn_detached_windows(cmd, env, stdout=stdout, stderr=stderr, stdin=stdin, cwd=cwd)
return _spawn_detached_posix(cmd, env, stdout=stdout, stderr=stderr, stdin=stdin, cwd=cwd)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -1874,6 +1893,7 @@ def _spawn_bg_child(
web_port: int,
pid_workflow_ref: Path,
forced_run_id: str | None = None,
cwd: Path | None = None,
) -> BackgroundLaunch:
"""Open the bg log files, spawn the detached child, and finalize the launch.

Expand All @@ -1894,17 +1914,35 @@ def _spawn_bg_child(
``_open_bg_log_files`` so the bg log filenames, the
``CONDUCTOR_RUN_ID`` env var, and the run-record poll key in
``_finalize_background_launch`` all agree on one id.
cwd: Working directory for the detached child (issue #477 -- the
Fleet Manager TUI's ``launch_dir``). ``None`` preserves the
child's inherited cwd. Validated to be an existing directory
*before* the bg log files are opened -- a bad ``cwd`` reaching
``subprocess.Popen`` directly would otherwise raise a bare
``FileNotFoundError`` indistinguishable from a missing
interpreter.

Returns:
``BackgroundLaunch`` describing the live launch.

Raises:
RuntimeError: If the log files cannot be created, the child fails to
start, the dashboard doesn't become reachable, or the child is
found to have exited with a non-zero code in the narrow window
between ``_finalize_background_launch`` reporting success and
this function's own final liveness check.
RuntimeError: If ``cwd`` is given and is not a directory, the log
files cannot be created, the child fails to start, the
dashboard doesn't become reachable, or the child is found to
have exited with a non-zero code in the narrow window between
``_finalize_background_launch`` reporting success and this
function's own final liveness check.
"""
if cwd is not None:
try:
ok = cwd.is_dir()
except OSError as exc:
raise RuntimeError(f"Working directory is not accessible: {cwd} ({exc})") from exc
if not ok:
if cwd.exists():
raise RuntimeError(f"Working directory is not a directory: {cwd}")
raise RuntimeError(f"Working directory does not exist: {cwd}")

try:
run_id, stderr_path, stdout_path, stderr_handle, stdout_handle = _open_bg_log_files(
pid_workflow_ref, forced_run_id=forced_run_id
Expand All @@ -1930,6 +1968,7 @@ def _spawn_bg_child(
_build_bg_env(run_id, web_port, stderr_path, stdout_path),
stdout=stdout_handle,
stderr=stderr_handle,
cwd=cwd,
)
except Exception as exc:
raise RuntimeError(
Expand Down Expand Up @@ -2010,6 +2049,7 @@ def launch_background(
workspace_instructions: bool = False,
cli_instructions: list[str] | None = None,
print_loaded_instructions: bool = False,
cwd: Path | None = None,
) -> BackgroundLaunch:
"""Fork a detached child process running the workflow with a web dashboard.

Expand All @@ -2032,6 +2072,9 @@ def launch_background(
print_loaded_instructions: Whether to forward ``--print-loaded-instructions``
to the background child. Output goes to the child's captured stderr
log, not to the parent's TTY.
cwd: Working directory for the detached child (issue #477); becomes
the run's recorded ``system.cwd``. ``None`` (every CLI path)
preserves the child's inherited cwd.

Returns:
A ``BackgroundLaunch`` describing the launch (dashboard URL,
Expand All @@ -2052,6 +2095,7 @@ def launch_background(
# enabled (see issue #196).
cmd: list[str] = [
sys.executable,
"-P",
"-m",
"conductor",
"run",
Expand Down Expand Up @@ -2093,7 +2137,7 @@ def launch_background(
if print_loaded_instructions:
cmd.append("--print-loaded-instructions")

return _spawn_bg_child(cmd=cmd, web_port=web_port, pid_workflow_ref=workflow_path)
return _spawn_bg_child(cmd=cmd, web_port=web_port, pid_workflow_ref=workflow_path, cwd=cwd)


def _peek_resume_run_id(workflow_path: Path | None, checkpoint_path: Path | None) -> str | None:
Expand Down Expand Up @@ -2175,6 +2219,7 @@ def launch_background_resume(
web_port: int = 0,
metadata: dict[str, str] | None = None,
guidance: list[str] | None = None,
cwd: Path | None = None,
) -> BackgroundLaunch:
"""Fork a detached child process resuming the workflow with a web dashboard.

Expand All @@ -2198,6 +2243,9 @@ def launch_background_resume(
metadata: Optional CLI metadata key=value pairs.
guidance: Optional mid-run guidance text(s) to apply before the
resumed agent runs. Forwarded as repeated ``--guidance`` flags.
cwd: Working directory for the detached child (issue #477); becomes
the run's recorded ``system.cwd``. ``None`` (every CLI path)
preserves the child's inherited cwd.

Returns:
A ``BackgroundLaunch`` describing the launch (dashboard URL,
Expand Down Expand Up @@ -2225,6 +2273,7 @@ def launch_background_resume(
# enabled (see issue #196).
cmd: list[str] = [
sys.executable,
"-P",
"-m",
"conductor",
"resume",
Expand Down Expand Up @@ -2282,6 +2331,7 @@ def launch_background_resume(
web_port=web_port,
pid_workflow_ref=pid_workflow_ref,
forced_run_id=forced_run_id,
cwd=cwd,
)


Expand Down
Loading
Loading