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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ step-by-step checklist.
- `run.py` - Workflow execution command with verbose logging helpers
- `bg_runner.py` - Background process forking for `--web-bg` mode. Captures the detached child's stdout/stderr to `$TMPDIR/conductor/conductor-<name>-<ts>-<runid>.bg.{stderr,stdout}.log` so silent crashes (uncaught Python exceptions, `faulthandler` dumps) leave a forensic trail — DEVNULL is **not** used for stdout/stderr. Passes `CONDUCTOR_RUN_ID`, `CONDUCTOR_BG_STDERR_LOG`, and `CONDUCTOR_BG_STDOUT_LOG` to the child via env so the child's `EventLogSubscriber` shares a run id with the bg log files and surfaces both paths in `workflow_started` system metadata. Returns a `BackgroundLaunch` dataclass (`url`, `stderr_log`, `stdout_log`, `run_id`). The launcher records that same `run_id` and both capture-log paths into the PID file (issue #404), rather than the launcher-invisible `run_id=""`/`log_file=""` defaults `write_pid_file` used to fall back to. `launch_background_resume` adopts the run id from the resolved checkpoint (`_checkpoint_run_id`, mirroring `cli/run.py`'s own checkpoint-resolution precedence) instead of minting a fresh one, so the PID file, `/api/info`, the events JSONL, and the capture-log filenames all agree on one id across a resume; a checkpoint with no usable id falls back to a fresh one.
- `pid.py` - PID file utilities for tracking/stopping background processes. The PID file records `run_id`, `stderr_log`, and `stdout_log` from the launch (see `bg_runner.py`); a PID file written before this field existed has `run_id` as an empty string and lacks the `stderr_log`/`stdout_log` keys entirely (they didn't exist yet) — both cases surface as JSON `null` via `conductor status --json`.
- `self_run.py` - Answers "is this PID-file entry the run I am executing inside?" for `conductor stop`'s self-exclusion (issue #399: an agent smoke-testing `stop` must not terminate its own workflow). Three signals, first match wins: (1) `CONDUCTOR_RUN_ID` matching the entry's `run_id`; (2) `CONDUCTOR_WEB_BG`/`CONDUCTOR_WEB_PORT` matching the entry's port, but *only* when the entry records no `run_id` (the pre-#411 compatibility path — an entry with a present-but-different id never falls back to this signal); (3) process ancestry (`/proc/<pid>/status` `PPid:` walk + `os.getsid(0)`), POSIX-only — Windows relies on signals 1–2 alone. `partition_own_run` splits PID-file entries into `others`/`own`; `stop` targets `others` unless `--allow-self` is passed.
- `update.py` - Update check and version comparison. Upgrades are delegated to the install script (`install.ps1`/`install.sh`); in-process self-upgrade was removed because on Windows the running Python interpreter sits inside the venv `uv tool install --force` is trying to recreate, which fails with "Access is denied". `conductor update` prints the OS-appropriate install-script one-liner; `conductor update --apply` spawns the installer detached (Windows: new console window; POSIX: `os.execvpe` replace) and exits the current process so file locks release. The startup hint is suppressed by `CONDUCTOR_NO_UPDATE_CHECK=1`, `--silent`, `--help`/`--version`, and the `update` subcommand itself.

- **config/**: YAML loading and Pydantic schema validation
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`conductor stop` no longer kills the run it is executing inside** (#399).
An agent smoke-testing `conductor stop` from its own workflow's `bash` tool
inherited that workflow's background environment and terminated itself —
the process printed "Stopped" and was killed by what it printed. `stop` now
identifies the run it is executing inside via `CONDUCTOR_RUN_ID` (set on
every `--web-bg` child and inherited by descendants), the legacy
`CONDUCTOR_WEB_BG`/`CONDUCTOR_WEB_PORT` pair (for PID files predating
#411's `run_id` field), and POSIX process ancestry, and excludes it from
targeting by default: `--all` now means "stop all *other* runs", the
no-flag auto-stop skips it, and `--port <your own port>` is refused (exit
`1`, naming `--allow-self` as the remedy). If only your own run is alive, `stop`/`stop --all`
print a refusal and exit `0` rather than erroring, since nothing named was
declined. Pass `--allow-self` to restore the previous targeting exactly; a
yellow warning is printed whenever it actually causes your own run to be
signalled. Process-ancestry detection is POSIX-only — Windows relies on
the env-var signals alone.

- **A pricing hook that silently prices nothing is now reported** (#386). #265
warns when the provider pricing hook *raises*; the companion case — a hook
that never raises and returns `None` for everything — looked identical to
Expand Down
56 changes: 52 additions & 4 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ conductor stop [OPTIONS]
| Option | Description |
|--------|-------------|
| `--port PORT` | Stop the workflow running on this specific port |
| `--all` | Stop all background conductor workflows |
| `--all` | Stop all *other* background conductor workflows (see Self-Exclusion) |
| `--allow-self` | Include the run this command is executing inside (refused by default) |
| `--force` | Proceed when the run's identity cannot be confirmed (see [Identity and `--force`](#identity-and---force)) |
| `--json` | Emit a machine-readable result per workflow on stdout instead of prose |

Expand All @@ -315,8 +316,8 @@ With no options, `conductor stop` lists running background workflows. If exactly

| Code | Meaning |
|------|---------|
| `0` | Every targeted workflow is confirmed stopped, or was already gone |
| `1` | `--port` matched no running workflow, or the target was ambiguous |
| `0` | Every targeted workflow is confirmed stopped, or was already gone — including a self-only refusal (see Self-Exclusion) |
| `1` | `--port` matched no running workflow, the target was ambiguous, or `--port` matched only your own run |
| `2` | At least one workflow survived, or could not be confirmed stopped |

Exit `2` is deliberately not a synonym for failure to signal — it means Conductor could not *prove* the process is gone. A run that ignored every rung and a run whose liveness could not be probed both land here, because both leave you with something you should look at by hand.
Expand Down Expand Up @@ -358,6 +359,50 @@ The web dashboard also exposes terminate controls that always preserve progress:
shows a **"Workflow Stopped"** banner with the checkpoint path (or a clear
explanation if no checkpoint could be saved).

### Self-Exclusion

`conductor stop` never targets the run it is executing inside by default — an
agent smoke-testing this command must not terminate its own workflow (issue
#399). This matters because nothing about a PID-file entry says "this is the
workflow driving you" — an agent's `bash` tool, and any `conductor stop` it
spawns, sits inside that very run's process tree, so a naive `stop` treats it
as fair game just like any other run.

The caller's own run is identified by three signals, tried in order (first
match wins):

1. **`CONDUCTOR_RUN_ID`** env var matching the PID file's `run_id`
(case-insensitively, since a manually-exported env var could differ in
case from the minted lowercase id). Set on every `--web-bg` child (and
inherited by its descendants, including a spawned `conductor stop`).
2. **`CONDUCTOR_WEB_BG=1` + `CONDUCTOR_WEB_PORT`** matching the entry's port —
a compatibility signal used only for PID files written before `run_id`
existed (empty `run_id`).
3. **Process ancestry** — a `/proc/<pid>/status` `PPid:` walk plus a session-id
check, so any descendant of the background process (however it was
re-parented) still resolves to it.

Effects:

- `conductor stop` (no flags) and `conductor stop --all` never stop your own
run; `--all` means "stop all *other* runs." If only your own run is alive,
both print a refusal and exit `0` (nothing was requested by name and
nothing failed).
- `conductor stop --port <your own port>` is refused and exits `1`, naming
`--allow-self` as the remedy — here a specific target was named and
declined.
- `conductor stop --allow-self [...]` restores the pre-#399 targeting exactly
(same processes, same counts), but now prints a yellow warning when the run
being stopped is your own.

**Windows caveat**: process ancestry (signal 3) is POSIX-only. On Windows,
self-identification relies solely on the `CONDUCTOR_RUN_ID` /
`CONDUCTOR_WEB_BG`+`CONDUCTOR_WEB_PORT` env vars — an agent whose tool runner
strips `CONDUCTOR_*` env vars before spawning its shell is still exposed.

See [`conductor status`](#conductor-status) for a non-destructive way to see
the full list of running workflows, including your own.

### Examples

```bash
Expand All @@ -367,8 +412,11 @@ conductor stop
# Stop a specific workflow by port
conductor stop --port 8080

# Stop all running background workflows
# Stop all other running background workflows
conductor stop --all

# Include the run this command is executing inside
conductor stop --allow-self --port 8080
```

## `conductor gate respond`
Expand Down
178 changes: 166 additions & 12 deletions src/conductor/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any

import typer
from rich.console import Console
Expand All @@ -22,6 +22,11 @@
from conductor.console import make_console, styled
from conductor.exceptions import WorkflowTerminated

if TYPE_CHECKING:
# Typing-only: ``stop()`` imports ``conductor.cli.self_run`` lazily at
# runtime, matching the existing lazy import of ``conductor.cli.pid``.
from conductor.cli.self_run import OwnRunPartition

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -1272,6 +1277,13 @@ def stop(
help="Stop all background conductor workflows.",
),
] = False,
allow_self: Annotated[
bool,
typer.Option(
"--allow-self",
help="Include the run this command is executing inside (refused by default).",
),
] = False,
force: Annotated[
bool,
typer.Option(
Expand Down Expand Up @@ -1310,10 +1322,21 @@ def stop(
dashboard, because a recorded PID may since have been recycled onto an
unrelated process. Use --force to override that check.

\b
By default, `stop` never targets the run it is executing inside --
an agent smoke-testing this command must not terminate its own
workflow (issue #399). That run is identified by `CONDUCTOR_RUN_ID`,
the legacy `CONDUCTOR_WEB_BG`/`CONDUCTOR_WEB_PORT` pair, or process
ancestry, and is excluded from `--all` and the no-flag auto-stop; a
`--port` naming it is refused outright. Pass `--allow-self` to
include it anyway.

\b
Exit codes:
0 every targeted workflow is confirmed stopped (or was already gone)
1 --port matched no running workflow, or the target was ambiguous
0 every targeted workflow is confirmed stopped (or was already
gone), including a self-only refusal
1 --port matched no running workflow, the target was ambiguous,
or --port matched only your own run
2 at least one workflow survived or could not be confirmed stopped

\b
Expand All @@ -1322,10 +1345,12 @@ def stop(
conductor stop --port 8080
conductor stop --all
conductor stop --all --json
conductor stop --allow-self --port 8080
"""
import json

from conductor.cli.pid import read_pid_files, remove_pid_file_at
from conductor.cli.self_run import partition_own_run

running = read_pid_files()

Expand All @@ -1338,11 +1363,44 @@ def stop(
)
return

partition = partition_own_run(running)
targetable = running if allow_self else partition.others
auto_detected_single = False

if all_workflows:
targets = running
if not allow_self and not targetable:
if json_output:
output_console.print_json(
json.dumps({"stopped": [], "failed": []}), ensure_ascii=True
)
else:
_print_self_exclusion(partition, console, blocking=True)
return
targets = targetable
if not allow_self and partition.own and not json_output:
_print_self_exclusion(partition, console, blocking=False)
elif port is not None:
targets = [e for e in running if e["port"] == port]
targets = [e for e in targetable if e["port"] == port]
if not targets:
if not allow_self:
own_match = [e for e in partition.own if e["port"] == port]
if own_match:
if json_output:
output_console.print_json(
json.dumps(
{
"error": (
f"port {port} is the run this command is executing "
"inside; pass --allow-self to include it"
)
}
),
ensure_ascii=True,
)
else:
_print_self_refusal_line(own_match[0], console)
_print_allow_self_hint(console)
raise typer.Exit(code=1)
if json_output:
output_console.print_json(
json.dumps({"error": f"no background workflow on port {port}"}),
Expand All @@ -1355,11 +1413,21 @@ def stop(
port,
)
)
console.print(Text.from_markup("[dim]Running workflows:[/dim]"))
_print_running_list(running, console)
if not allow_self and not targetable and partition.own:
_print_self_exclusion(partition, console, blocking=False)
else:
console.print(Text.from_markup("[dim]Running workflows:[/dim]"))
_print_running_list(targetable, console)
raise typer.Exit(code=1)
elif len(running) == 1:
targets = running
elif len(targetable) == 0:
if json_output:
output_console.print_json(json.dumps({"stopped": [], "failed": []}), ensure_ascii=True)
else:
_print_self_exclusion(partition, console, blocking=True)
return
elif len(targetable) == 1:
targets = targetable
auto_detected_single = True
else:
# Ambiguous: list rather than guess which run the user meant. This is
# a failure to act, so it must not report success to automation.
Expand All @@ -1372,21 +1440,27 @@ def stop(
console.print(
styled(
"[bold yellow]Multiple background workflows running ({}).[/bold yellow]",
len(running),
len(targetable),
)
)
console.print(
Text.from_markup(
"[dim]Specify --port to stop a specific one, or --all to stop all.[/dim]\n"
)
)
_print_running_list(running, console)
_print_running_list(targetable, console)
if not allow_self and partition.own:
_print_self_exclusion(partition, console, blocking=False)
raise typer.Exit(code=1)

# Prose goes to ``console`` (stderr); JSON goes to ``output_console``
# (stdout). They cannot corrupt each other, so diagnostics stay visible
# even in --json mode.
results = [_stop_process(entry, console, force=force) for entry in targets]
results = []
for entry in targets:
if allow_self:
_maybe_warn_stopping_self(entry, partition, console)
results.append(_stop_process(entry, console, force=force))

for entry, result in zip(targets, results, strict=True):
if result["outcome"] in ("stopped", "already-exited"):
Expand Down Expand Up @@ -1425,11 +1499,91 @@ def stop(
"failed": [r for r in results if r["outcome"] not in ("stopped", "already-exited")],
}
output_console.print_json(json.dumps(payload), ensure_ascii=True)
elif auto_detected_single and not allow_self and partition.own:
# Single-target auto-stop: the exclusion note comes after the stop
# so the user sees "Stopped <other>" before being told their own run
# was left out of consideration, matching the --all branch's note.
_print_self_exclusion(partition, console, blocking=False)

if any(r["outcome"] not in ("stopped", "already-exited") for r in results):
raise typer.Exit(code=2)


def _print_self_refusal_line(entry: dict, con: Console) -> None:
"""Print the red refusal line naming the run this command is executing inside.

Args:
entry: The PID-file dict identified as this process's own run.
con: Rich Console for output.
"""
from conductor.cli.self_run import describe_own_run

con.print(
styled(
"[bold red]Refusing[/bold red] to stop run {} — it is the run this "
"command is executing inside.",
describe_own_run(entry),
)
)


def _print_allow_self_hint(con: Console) -> None:
"""Print the dim hint pointing at the ``--allow-self`` escape hatch."""
con.print(Text.from_markup("[dim]Use --allow-self to include it.[/dim]"))


def _print_self_exclusion(partition: OwnRunPartition, con: Console, *, blocking: bool) -> None:
"""Print the message explaining that this run was excluded from targeting.

Args:
partition: The result of ``partition_own_run``. ``partition.own``
must be non-empty.
con: Rich Console for output.
blocking: True when there is nothing left to stop (prints the red
refusal line plus "No other workflows are running."); False when
other runs were still targeted (prints a yellow exclusion note).
"""
entry = partition.own[0]
if blocking:
_print_self_refusal_line(entry, con)
con.print(Text.from_markup("[dim]No other workflows are running.[/dim]"))
else:
from conductor.cli.self_run import describe_own_run

con.print(
styled(
"[yellow]Excluded[/yellow] run {} — it is the run this command is "
"executing inside.",
describe_own_run(entry),
)
)
_print_allow_self_hint(con)


def _maybe_warn_stopping_self(entry: dict, partition: OwnRunPartition, con: Console) -> None:
"""Print a yellow warning when about to signal the caller's own run.

Only reachable via ``--allow-self`` -- without that flag, an entry
identified as this process's own run is never present in the
targetable list in the first place.

Args:
entry: The PID-file dict about to be stopped.
partition: The result of ``partition_own_run``.
con: Rich Console for output.
"""
from conductor.cli.self_run import describe_own_run

if any(o["port"] == entry["port"] for o in partition.own):
con.print(
styled(
"[yellow]Warning:[/yellow] stopping run {} — this is the run "
"executing this command.",
describe_own_run(entry),
)
)


class Identity(str, Enum):
"""Result of checking that a PID file describes the process on its port.

Expand Down
Loading
Loading