End-to-end: ``python -m orchestrator --target-pid <pid> --duration N`` now
writes a complete episode directory matching docs/data-model.md, with phase
labels, events, and a 10 Hz host /proc telemetry stream. No VM yet — pid is
arbitrary so we can validate the loop against e.g. ``sleep 5`` while the lab
side comes up.
collectors/proc_qemu.py — parses /proc/<pid>/{stat,io,status} (handles parens
in comm), single-shot collect_once(), and a stop-event-driven run_loop()
that ticks at a fixed cadence and exits when the pid disappears. Tagged
``available_in_deployment: false`` per the threat-model doc.
orchestrator/episode.py — EpisodeRunner: creates data/episodes/<ulid>/,
atomic meta.json, events.jsonl + labels.jsonl writers, drives the collector
in a thread for duration_s, writes done.marker last so the shipper never
sees a half-finished episode.
orchestrator/ulid.py — tiny 26-char Crockford-base32 ULID generator.
Time-sortable, no third-party dep.
orchestrator/__main__.py — CLI entry point.
Tests (15 new, 28 total green):
- proc_qemu: real-ish stat with parens-in-comm, missing /proc/<pid>/io,
missing pid, run_loop cadence, run_loop terminates when pid disappears.
- episode: full directory shape against os.getpid(), id override,
done.marker written after meta.json finalize.
- ulid: length+alphabet, 2000-burst uniqueness, time-sortability.
Smoke-tested against ``sleep 10``: 16 rows over 1.5s at 100ms cadence,
monotonic clock, RSS stable at ~3.5 MiB as expected for an idle sleep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
2.8 KiB
Python
88 lines
2.8 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from orchestrator.episode import EpisodeConfig, EpisodeRunner
|
|
|
|
|
|
def _read_jsonl(p: Path) -> list[dict]:
|
|
return [json.loads(l) for l in p.read_text().splitlines()]
|
|
|
|
|
|
def test_episode_against_self_pid_produces_full_directory(tmp_path: Path) -> None:
|
|
cfg = EpisodeConfig(
|
|
target_pid=os.getpid(),
|
|
duration_s=0.5,
|
|
interval_ms=50,
|
|
data_root=tmp_path,
|
|
)
|
|
result = EpisodeRunner(cfg).run()
|
|
|
|
d = result.episode_dir
|
|
assert d.exists()
|
|
assert (d / "meta.json").exists()
|
|
assert (d / "events.jsonl").exists()
|
|
assert (d / "labels.jsonl").exists()
|
|
assert (d / "telemetry-proc.jsonl").exists()
|
|
assert (d / "done.marker").exists()
|
|
|
|
# meta.json structure
|
|
meta = json.loads((d / "meta.json").read_text())
|
|
assert meta["episode_id"] == result.episode_id
|
|
assert meta["schema_version"] == 1
|
|
assert meta["started_at_wall"] is not None
|
|
assert meta["ended_at_wall"] is not None
|
|
assert meta["vm"]["target_pid"] == os.getpid()
|
|
assert meta["schedule"]["baseline_seconds"] == 0.5
|
|
assert meta["schedule"]["interval_ms"] == 50
|
|
assert meta["result"]["rows_proc"] == result.rows_proc
|
|
assert "clean" in meta["result"]["phases_observed"]
|
|
|
|
# labels.jsonl: at least one clean label at t=0.
|
|
labels = _read_jsonl(d / "labels.jsonl")
|
|
assert any(r["phase"] == "clean" and r["t_mono_ns"] == 0 for r in labels)
|
|
|
|
# events.jsonl: snapshot_load + episode_end.
|
|
events = _read_jsonl(d / "events.jsonl")
|
|
event_names = [e["event"] for e in events]
|
|
assert "snapshot_load" in event_names
|
|
assert "episode_end" in event_names
|
|
|
|
# telemetry-proc.jsonl: roughly 10 ticks @ 50ms over 500ms.
|
|
proc_rows = _read_jsonl(d / "telemetry-proc.jsonl")
|
|
assert len(proc_rows) >= 5
|
|
for row in proc_rows:
|
|
assert row["source"] == "host_proc"
|
|
assert row["available_in_deployment"] is False
|
|
assert row["rss_bytes"] > 0
|
|
|
|
|
|
def test_episode_id_can_be_overridden(tmp_path: Path) -> None:
|
|
cfg = EpisodeConfig(
|
|
target_pid=os.getpid(),
|
|
duration_s=0.1,
|
|
interval_ms=50,
|
|
data_root=tmp_path,
|
|
episode_id="01TEST",
|
|
)
|
|
result = EpisodeRunner(cfg).run()
|
|
assert result.episode_id == "01TEST"
|
|
assert result.episode_dir == tmp_path / "episodes" / "01TEST"
|
|
|
|
|
|
def test_episode_writes_done_marker_last(tmp_path: Path) -> None:
|
|
"""done.marker should not appear until meta.json has ended_at_wall set."""
|
|
cfg = EpisodeConfig(
|
|
target_pid=os.getpid(),
|
|
duration_s=0.1,
|
|
interval_ms=50,
|
|
data_root=tmp_path,
|
|
)
|
|
result = EpisodeRunner(cfg).run()
|
|
assert (result.episode_dir / "done.marker").exists()
|
|
meta = json.loads((result.episode_dir / "meta.json").read_text())
|
|
assert meta["ended_at_wall"] is not None
|