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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ uv run conductor run workflow.yaml --web --input question="What is Python?"
uv run conductor run workflow.yaml --web-bg --input question="What is Python?"

# Stop a background workflow
uv run conductor status # list background workflows, never stops one
uv run conductor status --json # machine-readable
uv run conductor stop # auto-stop if one running, list if multiple
uv run conductor stop --port 8080 # stop specific port
uv run conductor stop --all # stop all background workflows
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`conductor status` — see what is running without stopping it** (#384).
`conductor stop` with no arguments lists background workflows, but stops one
when exactly one is running, so the natural "what's running?" reflex was
destructive precisely when there was a single run to lose. `status` never
terminates anything and never removes a PID file, so a run stays
discoverable even when its liveness cannot be confirmed. It prints each
run's dashboard URL, which is otherwise unrecoverable once the launching
terminal is gone, and `--json` makes it scriptable. A malformed PID file is
skipped with a warning rather than taking down the listing.
- **Git-backed plugin sources** (#380). `runtime.plugins` alone resolves
against machine state — an installed plugin name, or a path — so a workflow
shared with a teammate still needed "first install these plugins" in a
Expand Down
39 changes: 39 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Complete command-line reference for Conductor.

- [Root-Level Options](#root-level-options)
- [`conductor run`](#conductor-run)
- [`conductor status`](#conductor-status)
- [`conductor stop`](#conductor-stop)
- [`conductor gate respond`](#conductor-gate-respond)
- [`conductor checkpoint list`](#conductor-checkpoint-list)
Expand Down Expand Up @@ -226,6 +227,44 @@ Line 2
Line 3"
```

## `conductor status`

List background workflows launched with `--web-bg`, without stopping any of them.

```bash
conductor status [OPTIONS]
```

### Options

| Option | Description |
|--------|-------------|
| `--json` | Emit machine-readable output instead of a table |

### Why This Exists

`conductor stop` with no arguments also lists running workflows — but it *stops* one when exactly one is running, so the natural "what's running?" reflex is destructive precisely when there is a single run to lose. `conductor status` never terminates anything.

It is also read-only on disk: unlike `stop`, it never removes a PID file, so a run stays discoverable even if its liveness cannot be confirmed at that moment.

The dashboard URL is included because there is otherwise no supported way to recover it once the launching terminal is gone.

### Exit Codes

| Code | Meaning |
|------|---------|
| `0` | Listed successfully, including when nothing is running |

### Examples

```bash
# What is running right now?
conductor status

# Machine-readable, for scripts
conductor status --json
```

## `conductor stop`

Stop background workflow processes launched with `--web-bg`.
Expand Down
26 changes: 25 additions & 1 deletion plugins/conductor/skills/conductor/references/execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,31 @@ conductor run workflow.yaml --web-bg --input question="Hello"

The `--web` flag opens a browser dashboard with a DAG visualization showing live agent status, streaming reasoning/tool calls, and an agent detail panel. The `--web-bg` flag forks a background process and exits immediately. `--web` and `--web-bg` are mutually exclusive.

Background workflows can be stopped with `conductor stop` (see below) or via the stop button in the web dashboard.
Background workflows can be listed with `conductor status` (read-only), and stopped with `conductor stop` (see below) or via the stop button in the web dashboard.

### conductor status

List background workflows launched with `--web-bg`, without stopping any of them:

```bash
conductor status [OPTIONS]
```

| Option | Description |
|--------|-------------|
| `--json` | Emit machine-readable output instead of a table. |

Use this rather than a bare `conductor stop` to answer "what is running?". `conductor stop` with no arguments **stops** the workflow when exactly one is running, so the natural reflex is destructive precisely when there is a single run to lose. `status` never terminates anything, and it prints each run's dashboard URL, which is otherwise unrecoverable once the launching terminal is gone.

**Examples:**

```bash
# What is running right now?
conductor status

# Machine-readable, for scripts
conductor status --json
```

### conductor stop

Expand Down
80 changes: 77 additions & 3 deletions src/conductor/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,71 @@ async def _run_replay() -> None:
console.print("\n[dim]Replay stopped.[/dim]")


@app.command(rich_help_panel="Run & Recover")
def status(
json_output: Annotated[
bool,
typer.Option(
"--json",
help="Emit machine-readable output instead of a table.",
),
] = False,
) -> None:
"""List background workflows without stopping any of them.

\b
`conductor stop` also lists running workflows, but it stops one when
exactly one is running -- so the natural "what's running?" reflex is
destructive precisely when there is a single run to lose. This command is
read-only and always safe.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the sentence a user leans on to decide whether running the command is safe, and it is not accurate. read_pid_files() unlinks PID files on three paths (pid.py:150, :155, :164). Since Typer renders this as --help, the claim ships as product copy.

Suggested change
read-only and always safe.
never signals or terminates a process.


The dashboard URL is included because there is otherwise no supported way
to recover it once the launching terminal is gone.

\b
Exit codes:
0 listed successfully (including when nothing is running)

\b
Examples:
conductor status
conductor status --json
"""
import json

from conductor.cli.pid import scan_pid_files

# Deliberately not ``read_pid_files``: that one prunes as it reads, which
# would make the read-only command destructive — the exact trap this
# command exists to give people an alternative to.
running = scan_pid_files()

if json_output:
payload = [
{
"pid": e["pid"],
"port": e["port"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

port is indexed directly while the fields around it use .get(), and read_pid_files only validates pid. A PID file without port raises KeyError here, so the command exits 1 with a traceback and empty stdout. Same on the table path at line 1316.

One bad file takes out the listing for every healthy run beside it, which is the failure mode most likely to send someone relaunching. For --json consumers there is nothing on stdout to parse.

Skipping bad entries and reporting them (a sibling unreadable key in the payload, a warning on stderr for the table) keeps the good rows visible. Valid JSON that is not an object also reaches data.get("pid") at pid.py:153 and raises AttributeError outside the existing guard.

"workflow": str(e.get("workflow", "")),
"run_id": e.get("run_id", ""),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_id and log_file are always empty in practice. The only production writer is _finalize_background_launch() at bg_runner.py:394, which calls write_pid_file(proc.pid, web_port, pid_workflow_ref) and leaves both at their "" defaults (pid.py:99-100). Confirmed on a real launch.

Automation will branch on these and take the else path every time. run_id is in scope at the call site, so threading it through is small; log_file is harder, since only the child knows its events log path, though the parent does have the bg stderr and stdout paths. Dropping them until something writes them is also fine, but shipping fields that are structurally empty is the one option I would avoid.

"started_at": e.get("started_at", ""),
"log_file": e.get("log_file", ""),
"url": f"http://127.0.0.1:{e['port']}",
}
for e in running
]
output_console.print_json(json.dumps({"running": payload}), ensure_ascii=True)
return

if not running:
console.print("[dim]No background workflows are currently running.[/dim]")
return

_print_running_list(running, console, show_url=True)
console.print(
f"\n[dim]{len(running)} running. Use 'conductor stop --port <PORT>' to stop one.[/dim]"
)


@app.command(rich_help_panel="Run & Recover")
def stop(
port: Annotated[
Expand Down Expand Up @@ -1229,12 +1294,16 @@ def _stop_process(entry: dict, con: Console) -> None:
)


def _print_running_list(entries: list[dict], con: Console) -> None:
def _print_running_list(entries: list[dict], con: Console, show_url: bool = False) -> None:
"""Print a table of running background workflows.

Args:
entries: List of PID-file dicts.
con: Rich Console for output.
show_url: Append a Dashboard URL column. Defaults to False;
``conductor status`` passes True, since discovery is its whole
purpose and the URL is otherwise unrecoverable once the launching
terminal is gone.
"""
from rich.table import Table

Expand All @@ -1243,14 +1312,19 @@ def _print_running_list(entries: list[dict], con: Console) -> None:
table.add_column("PID", style="yellow")
table.add_column("Workflow", style="white")
table.add_column("Started", style="dim")
if show_url:
table.add_column("Dashboard", style="blue")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL gets cropped at the default terminal width. Production started_at is a full datetime.now(UTC).isoformat() at 32 characters, 13 wider than the 19-character value the tests write, and Rich crops rather than wraps. At 80 columns with a realistic workflow name:

| 53941 | 72319 | code-review-pipel... | 2026-08-10T14:02:... | http://127.0.0.1:... |

So the field the command exists to surface is the one that disappears. overflow="fold" on this column, or trimming sub-second precision from Started, would fix it.

Suggested change
table.add_column("Dashboard", style="blue")
table.add_column("Dashboard", style="blue", overflow="fold")


for e in entries:
table.add_row(
row = [
str(e["port"]),
str(e["pid"]),
Path(e.get("workflow", "unknown")).stem,
e.get("started_at", "?"),
)
]
if show_url:
row.append(f"http://127.0.0.1:{e['port']}")
table.add_row(*row)

con.print(table)

Expand Down
48 changes: 48 additions & 0 deletions src/conductor/cli/pid.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,54 @@ def write_pid_file(
return filepath


def scan_pid_files() -> list[dict]:
"""Read every PID file **without modifying anything on disk**.

:func:`read_pid_files` is the maintenance path: it prunes as it goes, which
is right for ``stop`` and wrong for anything whose contract is to observe.
A reader that deletes turns a diagnostic command into the one that loses the
run it was asked about — and because ``write_pid_file`` is not atomic, a
scan landing inside a launch can see a half-written file and treat a live
workflow as garbage.

Malformed entries are skipped rather than raised on: one unparseable file in
the directory must not take down the listing of every other run. Entries
without an integer ``pid`` and ``port`` are skipped too, because every
caller indexes both.

Returns:
List of dicts for processes that are still alive, in filename order,
each with the PID file's contents plus ``file``.
"""
d = pid_dir()
results: list[dict] = []

# Sorted rather than raw glob order: the listing is user-facing, and
# ``Path.glob`` order is filesystem-dependent.
for f in sorted(d.glob("*.pid")):
try:
data = json.loads(f.read_text())
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Skipping unreadable PID file %s: %s", f, exc)
continue

if not isinstance(data, dict):
logger.warning("Skipping PID file whose contents are not an object: %s", f)
continue

pid = data.get("pid")
port = data.get("port")
if not isinstance(pid, int) or not isinstance(port, int):
logger.warning("Skipping PID file without an integer pid and port: %s", f)
continue

if _is_process_alive(pid):
data["file"] = str(f)
results.append(data)

return results


def read_pid_files() -> list[dict]:
"""Read all PID files and return info for processes that are still alive.

Expand Down
13 changes: 12 additions & 1 deletion tests/test_cli/test_help_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@ def test_all_panel_titles_present(self) -> None:
def test_flat_commands_listed(self) -> None:
result = runner.invoke(app, ["--help"], env=_WIDE)
assert result.exit_code == 0
for cmd in ("run", "resume", "stop", "replay", "validate", "show", "update", "doctor"):
for cmd in (
"run",
"resume",
"status",
"stop",
"replay",
"validate",
"show",
"update",
"doctor",
):
assert cmd in result.output

def test_noun_groups_listed(self) -> None:
Expand All @@ -59,6 +69,7 @@ def test_commands_mapped_to_correct_panels(self) -> None:
expected = {
"run": "Run & Recover",
"resume": "Run & Recover",
"status": "Run & Recover",
"stop": "Run & Recover",
"replay": "Run & Recover",
"validate": "Author & Inspect",
Expand Down
Loading
Loading