Skip to content
Open
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
30 changes: 29 additions & 1 deletion src/conductor/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ def generate_log_path(workflow_name: str) -> Path:
return path


def _resolve_event_log_dir(
event_log_dir: str | None,
workflow_path: Path,
) -> Path | None:
"""Resolve an event-log directory relative to its workflow file."""
if not event_log_dir:
return None

path = Path(event_log_dir)
if not path.is_absolute():
path = workflow_path.resolve().parent / path
return path.resolve()


def init_file_logging(log_path: Path) -> None:
"""Initialize file logging to the given path.

Expand Down Expand Up @@ -2054,7 +2068,15 @@ async def run_workflow_async(
# Start JSONL event log subscriber (always-on structured diagnostics)
from conductor.engine.event_log import EventLogSubscriber

event_log_subscriber = EventLogSubscriber(config.workflow.name)
_event_log_dir = _resolve_event_log_dir(
config.workflow.runtime.event_log_dir,
workflow_path,
)

event_log_subscriber = EventLogSubscriber(
config.workflow.name,
event_log_dir=_event_log_dir,
)
emitter.subscribe(event_log_subscriber.on_event)

# Write the Fleet Manager run record (E2): this is the first point
Expand Down Expand Up @@ -2765,10 +2787,16 @@ async def resume_workflow_async(
# appends to it and reuses run_id; otherwise it generates fresh.
from conductor.engine.event_log import EventLogSubscriber

_event_log_dir = _resolve_event_log_dir(
config.workflow.runtime.event_log_dir,
resolved_workflow_path,
)

event_log_subscriber = EventLogSubscriber(
config.workflow.name,
existing_path=existing_log_path,
existing_run_id=cp.run_id or None,
event_log_dir=_event_log_dir,
)
emitter.subscribe(event_log_subscriber.on_event)

Expand Down
10 changes: 10 additions & 0 deletions src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3452,6 +3452,16 @@ def _coerce_provider(cls, value: Any) -> Any:
provider declares ``capabilities.working_dir=False``.
"""

event_log_dir: str | None = None
"""Directory for event log output.

When set, event logs are written to this directory instead of
``$TMPDIR/conductor/``. Relative paths are resolved against the
workflow file's directory.

When omitted, behavior is unchanged (writes to ``$TMPDIR/conductor/``).
"""
Comment on lines +3456 to +3463

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 sentence about relative paths does not match cli/run.py:2058, which resolves against the process working directory. See my note there. Whichever way that goes, this text has to agree with it. Worth noting spill_dir a few hundred lines up has the same mechanics and documents the opposite rule, so right now two adjacent fields contradict each other.

There is also no validator. event_log_dir: " " is truthy, so it creates a directory named with three spaces. "" happens to behave, but only because run.py tests truthiness while event_log.py:155 tests is not None. That split is what _normalize_spill_dir at line 3123 exists to prevent, and its docstring says so directly.

@field_validator("event_log_dir")
@classmethod
def _normalize_event_log_dir(cls, v: str | None) -> str | None:
    """Normalize an empty or whitespace-only value to None.

    cli/run.py tests this field for truthiness while EventLogSubscriber
    tests it for `is not None`, so without one normalization point an
    empty string means "default directory" to one consumer and
    "process cwd" to the other.
    """
    if v is None:
        return None
    return v.strip() or None

Two things the docstring should also say: a resumed run keeps writing to the log recorded in its checkpoint and ignores this setting, and a log written outside $TMPDIR/conductor/ is skipped by the retention sweep and by conductor fleet History.

grep event_log_dir docs/ currently comes back empty, and docs/cli-reference.md:333 still states the log lives under $TMPDIR/conductor/.


skills: list[str] = Field(default_factory=list)
"""Workflow-wide default skills for every provider-backed agent.

Expand Down
10 changes: 6 additions & 4 deletions src/conductor/engine/event_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def __init__(
*,
existing_path: Path | None = None,
existing_run_id: str | None = None,
event_log_dir: Path | None = None,

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 Args: block below documents the other three parameters but not this one. The precedence rule is missing too: event_log_dir is ignored when the subscriber appends to existing_path. That behaviour is correct, since a resumed run should keep one continuous log, but a test is currently the only place it is recorded.

Something like:

    event_log_dir: Base directory for a freshly created log, replacing
        the default ``$TMPDIR/conductor/``. Ignored when the
        ``existing_path`` append branch is taken, so a resumed run keeps
        writing to the log its checkpoint points at. Callers resolve
        relative paths and ``~`` before passing a value here.

The case this leaves confusing: set event_log_dir after an initial run, then resume. The new directory stays empty, the log keeps growing in $TMPDIR/conductor/ where the retention sweep will eventually remove it, and nothing says why. A logger.info on that branch would cover it. The sibling branch already logs when it cannot append, so the asymmetry stands out.

The module docstring at lines 3-4 also still says the log goes to $TMPDIR/conductor/ without qualification.

) -> None:
"""Initialise the subscriber.

Expand Down Expand Up @@ -150,11 +151,12 @@ def __init__(
)
self._run_id = new_run_id()
ts = time.strftime("%Y%m%d-%H%M%S")
self._path = (
Path(tempfile.gettempdir())
/ "conductor"
/ f"conductor-{workflow_name}-{ts}-{self._run_id}.events.jsonl"
base_dir = (
event_log_dir
if event_log_dir is not None
else Path(tempfile.gettempdir()) / "conductor"
)
Comment on lines +154 to 158

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.

Minor: this collapses to one line. PurePath defines neither __bool__ nor __len__, so every Path is truthy and None is the only falsy input, which makes or equivalent to the is not None test here.

Suggested change
base_dir = (
event_log_dir
if event_log_dir is not None
else Path(tempfile.gettempdir()) / "conductor"
)
base_dir = event_log_dir or Path(tempfile.gettempdir()) / "conductor"

That equivalence depends on the parameter staying Path | None. If it is ever widened to accept str, an empty string becomes falsy and or would quietly swallow it.

self._path = base_dir / f"conductor-{workflow_name}-{ts}-{self._run_id}.events.jsonl"

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.

Worth deciding explicitly what should happen to a run whose log lands outside $TMPDIR/conductor/. Five places derive that directory independently and none of them consult the new setting:

  • fleet/retention.py:76 (event_log_root), so the log is never pruned and the directory grows without bound
  • fleet/history.py:476, so the run never appears in conductor fleet History
  • fleet/resume.py, which joins checkpoints against History entries, so the Resume action is never offered for it
  • fleet/records.py:889, the event-log recovery lookup
  • cli/bg_runner.py:1058, which still writes .bg.stderr.log and .bg.stdout.log to $TMPDIR/conductor/ while the events log moves elsewhere

I confirmed the History case: one entry from build_history_entries() with the default location, zero with event_log_dir set.

That last item has a user-visible consequence of its own. cli/app.py:219 locates the capture logs with Path(record.event_log_path).parent, so conductor status --json reports stderr_log: null for a background run whose capture logs are sitting one directory over. It also breaks the --web-bg debugging procedure in AGENTS.md, which depends on the three artifacts sharing a directory, and retention's guarantee that they are kept or removed together.

Either route these readers through a shared accessor, or say plainly in the field docstring that a custom directory opts the run out of History, retention, and background log correlation. The empty History screen is the part I would not leave silent. AGENTS.md is explicit that showing an empty state for a directory that was never read is a claim of absence that reads like success.

self._path.parent.mkdir(parents=True, exist_ok=True)
self._handle = open(self._path, "w", encoding="utf-8") # noqa: SIM115

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.

Now that this path can come from user config, these two lines can raise. I checked both cases: pointing event_log_dir at an existing file gives FileExistsError, and an unwritable directory gives PermissionError. Neither run_workflow_async nor resume_workflow_async wraps the call in an except, so it reaches print_error and the user gets a panel titled PermissionError containing [Errno 13] Permission denied: '/sys/conductor-logs'. The field name never appears, and the workflow exits 1 before running a single step.

So a typo in a diagnostics setting now stops work that would otherwise have succeeded, which is the opposite of what an always-on flight recorder should do.

Everything comparable in this codebase degrades instead. The existing_path branch at lines 112-125 catches OSError and creates a fresh log with a warning. --log-file warns and continues (run.py:1962). The dashboard warns and continues (run.py:2016). spill_dir in mcp/manager.py:450 is documented as best effort that must never raise.

Catching OSError here and falling back to $TMPDIR/conductor/ with a warning naming the field would match the rest, and it keeps the log alive rather than trading it for a dead run. If strict failure is preferred instead, it should be a config error raised by conductor validate, which currently passes clean on all of these values.


Expand Down
77 changes: 77 additions & 0 deletions tests/test_cli/test_event_log_dir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for event log directory path resolution."""

from pathlib import Path

import pytest

from conductor.cli.run import _resolve_event_log_dir


def test_relative_event_log_dir_is_resolved_from_workflow_directory(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Relative paths use the workflow file directory, not process CWD."""
workflow_dir = tmp_path / "workflow"
workflow_dir.mkdir()
workflow_path = workflow_dir / "workflow.yaml"
workflow_path.write_text("", encoding="utf-8")

other_cwd = tmp_path / "elsewhere"
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)

result = _resolve_event_log_dir("./logs", workflow_path)

assert result == (workflow_dir / "logs").resolve()
assert result != (other_cwd / "logs").resolve()


def test_parent_relative_event_log_dir_is_workflow_relative(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Parent components are evaluated from the workflow directory."""
workflow_dir = tmp_path / "project" / "workflows"
workflow_dir.mkdir(parents=True)
workflow_path = workflow_dir / "workflow.yaml"
workflow_path.write_text("", encoding="utf-8")

other_cwd = tmp_path / "elsewhere"
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)

result = _resolve_event_log_dir("../logs", workflow_path)

assert result == (workflow_dir.parent / "logs").resolve()


def test_absolute_event_log_dir_is_unchanged(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Absolute paths do not depend on workflow location or process CWD."""
workflow_dir = tmp_path / "workflow"
workflow_dir.mkdir()
workflow_path = workflow_dir / "workflow.yaml"
workflow_path.write_text("", encoding="utf-8")

other_cwd = tmp_path / "elsewhere"
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)

absolute_dir = tmp_path / "absolute-logs"

assert _resolve_event_log_dir(str(absolute_dir), workflow_path) == absolute_dir.resolve()


@pytest.mark.parametrize("value", [None, ""])
def test_missing_event_log_dir_remains_unset(
tmp_path: Path,
value: str | None,
) -> None:
"""Omitted and empty values retain the default TMPDIR behavior."""
workflow_path = tmp_path / "workflow.yaml"
workflow_path.write_text("", encoding="utf-8")

assert _resolve_event_log_dir(value, workflow_path) is None
69 changes: 69 additions & 0 deletions tests/test_engine/test_event_log_dir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Tests for configurable event log output directory."""

import tempfile
import time
from pathlib import Path

from conductor.engine.event_log import EventLogSubscriber
from conductor.events import WorkflowEvent


def test_runtime_config_event_log_dir():

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.

Every test in this file hands the subscriber a Path that is already absolute, so the piece this PR actually adds, the conversion from the YAML string in cli/run.py, is never exercised. That is why the relative-path and ~ behaviour went unnoticed.

Cases worth adding: a relative value where the process runs somewhere other than the workflow file's directory, ~, whitespace, a path that already exists as a file, and the resume wiring. tests/test_fleet/test_run_record_wiring.py is a natural home for the last one, since it already drives both async helpers and can assert where record.event_log_path lands.

Two smaller notes. This test exercises RuntimeConfig rather than the engine, so tests/test_config/ fits better. And test_default_writes_to_tmpdir largely repeats test_event_log.py:16, whose TestEventLogSubscriber class already owns this surface, so these might be more discoverable as methods there.

Line 17 also has a stray pair of parens around the string literal.

"""RuntimeConfig accepts event_log_dir field, defaults to None."""
from conductor.config.schema import RuntimeConfig

assert RuntimeConfig().event_log_dir is None
assert RuntimeConfig(event_log_dir="./logs").event_log_dir == "./logs"
assert RuntimeConfig(event_log_dir="/var/log/conductor").event_log_dir == ("/var/log/conductor")


def test_default_writes_to_tmpdir():
"""Without event_log_dir, writes to $TMPDIR/conductor/ (existing behavior)."""
sub = EventLogSubscriber("test_wf")
try:
assert sub.path.parent == Path(tempfile.gettempdir()) / "conductor"
finally:
sub.close()


def test_custom_event_log_dir(tmp_path):
"""With event_log_dir, writes to the specified directory."""
sub = EventLogSubscriber(
"test_wf",
event_log_dir=tmp_path / "logs",
)
try:
assert sub.path.parent == tmp_path / "logs"
assert sub.path.exists()

sub.on_event(
WorkflowEvent(
type="test",
timestamp=time.time(),
data={},
)
)
sub.close()

assert sub.path.read_text().strip()
finally:
if not sub._handle.closed:
sub.close()
Comment on lines +49 to +51

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.

close() is already idempotent. event_log.py:194 guards with self._handle is not None and not self._handle.closed, and test_event_log.py:73 already pins that with a double close. This guard re-implements the check and reaches into a private attribute to do it.

Suggested change
finally:
if not sub._handle.closed:
sub.close()
finally:
sub.close()



def test_existing_path_overrides_event_log_dir(tmp_path):
"""On resume, existing_path takes precedence over event_log_dir."""
existing = tmp_path / "existing.events.jsonl"
existing.write_text("")

sub = EventLogSubscriber(
"test_wf",
existing_path=existing,
existing_run_id="abcd1234",
event_log_dir=tmp_path / "custom",
)

try:
assert sub.path == existing
finally:
sub.close()
24 changes: 19 additions & 5 deletions tests/test_fleet/test_run_record_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def _mock_config(name: str = "wiring-test", agent_names: list[str] | None = None
mock_config.parallel = []
mock_config.for_each = []
mock_config.workflow.runtime.provider = ProviderSettings(name="copilot")
mock_config.workflow.runtime.event_log_dir = None
mock_config.workflow.limits.max_iterations = 50
mock_config.workflow.limits.timeout_seconds = None
mock_config.workflow.limits.budget_usd = None
Expand Down Expand Up @@ -153,12 +154,16 @@ class TestRunWorkflowAsyncRunRecordWiring:
"""``run_workflow_async`` writes a discoverable record on every path."""

async def test_fg_record_has_no_port_and_is_removed_on_completion(
self, tmp_path: Path, fleet_env: Path
self, tmp_path: Path, fleet_env: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from conductor.cli.run import run_workflow_async

wf_path = _write_workflow(tmp_path)
workflow_dir = tmp_path / "workflow"
workflow_dir.mkdir()
monkeypatch.chdir(tmp_path)
wf_path = _write_workflow(workflow_dir)
mock_config = _mock_config()
mock_config.workflow.runtime.event_log_dir = "./event-logs"
seen: dict[str, Any] = {}

async def _fake_run(inputs: dict[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -200,6 +205,7 @@ async def _fake_run(inputs: dict[str, Any]) -> dict[str, Any]:
# so this assertion actually exercises the distinction.
assert record.workflow_name == wf_path.stem == "wf"
assert Path(record.event_log_path).exists()
assert Path(record.event_log_path).parent == (workflow_dir / "event-logs").resolve()
assert record.checkpoint_dir is not None

# Removed once the run finishes.
Expand Down Expand Up @@ -535,12 +541,18 @@ async def _fake_resume(current_agent: str) -> dict[str, Any]:
assert read_run_records() == []

async def test_resume_record_removed_on_workflow_terminated(
self, tmp_path: Path, fleet_env: Path
self, tmp_path: Path, fleet_env: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from conductor.cli.run import resume_workflow_async

wf_path, cp_path = _write_checkpoint_and_config(tmp_path)
workflow_dir = tmp_path / "workflow"
workflow_dir.mkdir()
monkeypatch.chdir(tmp_path)
wf_path, cp_path = _write_checkpoint_and_config(workflow_dir)
(workflow_dir / "original.events.jsonl").unlink()
mock_config = _mock_config(agent_names=["a"])
mock_config.workflow.runtime.event_log_dir = "./event-logs"
expected_dir = (workflow_dir / "event-logs").resolve()

terminate_exc = WorkflowTerminated(
"bye",
Expand All @@ -550,7 +562,9 @@ async def test_resume_record_removed_on_workflow_terminated(
)

async def _fake_resume(current_agent: str) -> dict[str, Any]:
assert len(read_run_records()) == 1
records = read_run_records()
assert len(records) == 1
assert Path(records[0].event_log_path).parent == expected_dir
raise terminate_exc

mock_engine = MagicMock()
Expand Down
Loading