From 5f58c6421a6a38b75d7182cb3d12072d8eb31dd8 Mon Sep 17 00:00:00 2001 From: 3ae3ae Date: Mon, 19 Oct 2026 21:57:55 +0900 Subject: [PATCH] feat: background runs, stop, and JSON errors for agent use - `harness2 run --detach` starts the same run as a background process, logging to /run.log, and returns its name, pid and log path at once (an immediate failure is reported with the log's tail). - A running run records itself in /run.pid; a second start of the same run is refused, and `harness2 stop [NAME]` asks it to stop: the calls in flight finish, the run exits, and rerunning resumes it. Only a detached run's own process group is signalled, never the shell or agent that launched a foreground run. - With --json, a failing command prints {"error", "exit_code"} on stdout instead of an error line; `diff --json` returns the changes and per-file diffs. - `apply -i` and `chat` refuse to start without a terminal instead of waiting for input, and the agent guide says how to change init's choices with `config set`. Co-Authored-By: Claude Opus 5.5 --- harness2/cli/_common.py | 28 +++ harness2/cli/apply.py | 4 + harness2/cli/chat.py | 3 + harness2/cli/diff.py | 23 +-- harness2/cli/guide.py | 3 +- harness2/cli/main.py | 9 +- harness2/cli/run.py | 82 ++++++++- harness2/cli/stop.py | 59 +++++++ tests/test_agent_ops.py | 353 ++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 8 +- tests/test_consolidate.py | 8 +- 11 files changed, 557 insertions(+), 23 deletions(-) create mode 100644 harness2/cli/stop.py create mode 100644 tests/test_agent_ops.py diff --git a/harness2/cli/_common.py b/harness2/cli/_common.py index 2d013b59..c948c96b 100644 --- a/harness2/cli/_common.py +++ b/harness2/cli/_common.py @@ -119,6 +119,34 @@ def resolve_run(ctx: Context, name: str | None) -> Path: return max(runs, key=lambda p: p.stat().st_mtime_ns) +def run_process(run_dir: Path) -> dict | None: + path = run_dir / "run.pid" + try: + record = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(record, dict) + or type(record.get("pid")) is not int + or record["pid"] <= 0 + or not isinstance(record.get("started"), str) + or not isinstance(record.get("argv"), list) + or any(not isinstance(arg, str) for arg in record["argv"]) + ): + raise ValueError("invalid run process record") + except FileNotFoundError: + return None + except ValueError: + path.unlink(missing_ok=True) + return None + try: + os.kill(record["pid"], 0) + except ProcessLookupError: + path.unlink(missing_ok=True) + return None + except PermissionError: + pass + return record + + def load_run(run_dir: Path) -> dict: try: record = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) diff --git a/harness2/cli/apply.py b/harness2/cli/apply.py index 2cee874e..227938af 100644 --- a/harness2/cli/apply.py +++ b/harness2/cli/apply.py @@ -141,6 +141,10 @@ def summary(applied=(), done=None) -> dict: if args.dry_run: return 0, summary() + if args.interactive and not sys.stdin.isatty(): + message = "--interactive needs a terminal; use --yes or --dry-run instead." + print(message, file=sys.stderr) + return 1, {"error": message, "exit_code": 1} if args.interactive: chosen = _review(todo, current, adopted) elif args.yes: diff --git a/harness2/cli/chat.py b/harness2/cli/chat.py index 9c046b7c..6d5f838a 100644 --- a/harness2/cli/chat.py +++ b/harness2/cli/chat.py @@ -49,6 +49,9 @@ def add_parser(sub) -> argparse.ArgumentParser: def run(args, ctx) -> int: + if not sys.stdin.isatty(): + print("harness2 chat is a conversation and needs a terminal.", file=sys.stderr) + return 1 tool = args.tool or _default_tool(ctx.settings) binary = shutil.which(tool) if tool else None if not binary: diff --git a/harness2/cli/diff.py b/harness2/cli/diff.py index 274d9944..4a2e8fcc 100644 --- a/harness2/cli/diff.py +++ b/harness2/cli/diff.py @@ -45,15 +45,18 @@ def run(args, ctx) -> int: adapter, root = _adopt.open_run(ctx, args.name) task, after = _adopt.harness_for(adapter, root, args.task, args.mode) before = _adopt.base_tree(adapter) - data = ( - diff_summary(adapter.substrate.surface(), before, after) - if args.stat - else { - "task": task, - "diff": "".join( - _adopt.unified(rel, before, after) for rel, _ in _adopt.changes(before, after) - ), - } + changes = _adopt.changes(before, after) + data = { + "label": task, + "changes": changes, + "diffs": {rel: _adopt.unified(rel, before, after) for rel, _ in changes}, + } + ctx.out( + data, + lambda row: ( + str(diff_summary(adapter.substrate.surface(), before, after)) + if args.stat + else "".join(row["diffs"].values()).rstrip("\n") + ), ) - ctx.out(data, str if args.stat else lambda row: row["diff"].rstrip("\n")) return 0 diff --git a/harness2/cli/guide.py b/harness2/cli/guide.py index 8dc29532..240adff7 100644 --- a/harness2/cli/guide.py +++ b/harness2/cli/guide.py @@ -48,7 +48,8 @@ 1. `harness2 init --yes [--executor claude|codex] [--auth login|api-key]` creates .harness2/. `--auth login` uses the agent CLI's own login (a Claude or ChatGPT - subscription). + subscription). Change any choice afterwards with `harness2 config set KEY VALUE`, for + example `executor.model`. 2. Tasks. `harness2 task new [--files] [--check ""]`, then write the request in .harness2/tasks//task.md the way the person would really ask for it. Good tasks are real, varied work the person wants done better. A `check` command (exit status 0 means diff --git a/harness2/cli/main.py b/harness2/cli/main.py index 67c82872..aec4bc86 100644 --- a/harness2/cli/main.py +++ b/harness2/cli/main.py @@ -29,6 +29,7 @@ "task", "run", "status", + "stop", "judge", "report", "runs", @@ -94,14 +95,20 @@ def main(argv: list[str] | None = None) -> int: break argv.insert(index, alias) args = build_parser().parse_args(argv) + args.argv = argv ctx = Context(args, getattr(args._module, "FLAGS", {})) try: ctx.debug(f"command: {args.command}") return args._module.run(args, ctx) except KeyboardInterrupt: + if args.json: + ctx.out({"error": "interrupted", "exit_code": 130}, str) return 130 except (OSError, RuntimeError, ValueError, KeyError) as exc: - ctx.log(f"error: {exc}") + if args.json: + ctx.out({"error": str(exc), "exit_code": 1}, str) + else: + ctx.log(f"error: {exc}") return 1 diff --git a/harness2/cli/run.py b/harness2/cli/run.py index c95dfe58..31680703 100644 --- a/harness2/cli/run.py +++ b/harness2/cli/run.py @@ -17,7 +17,11 @@ from __future__ import annotations import argparse +import json +import os +import subprocess import sys +from datetime import datetime, timezone from pathlib import Path from .. import core, registry @@ -81,6 +85,9 @@ def add_parser(subparsers): p.add_argument( "--dry-run", action="store_true", help="show the tasks and the model calls; run nothing" ) + p.add_argument( + "--detach", action="store_true", help="start the run in the background and return at once" + ) return p @@ -159,15 +166,58 @@ def _print_stream(data) -> str: return "\n".join(lines) +def _detach(args, ctx, name: str, root: Path) -> int: + root.mkdir(parents=True, exist_ok=True) + log = root / "run.log" + argv = [arg for arg in args.argv if arg != "--detach"] + if args.name is None: + argv.extend(["--name", name]) + with log.open("a", encoding="utf-8") as output: + child = subprocess.Popen( + [sys.executable, "-m", "harness2", *argv], + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + code = child.wait(timeout=2) + except subprocess.TimeoutExpired: + code = None + if code: + print( + "\n".join(log.read_text(encoding="utf-8", errors="replace").splitlines()[-20:]), + file=sys.stderr, + ) + return code + ctx.out( + {"name": name, "pid": child.pid, "log": log}, + lambda data: ( + f"Started run {name} in the background (pid {data['pid']}).\n" + f"Follow it: harness2 status {name} --watch\n" + f"Stop it: harness2 stop {name}\n" + f"Log: {log}" + ), + ) + return 0 + + def run(args, ctx) -> int: events.start(None) status, reason, name = "error", "", args.name or "" + pid_path = None try: + if args.detach and args.dry_run: + raise ConfigError("--detach starts a real run; drop --dry-run") project, config = ctx.project, ctx.settings k, seed = config.get("run.k"), config.get("run.seed") modes = C.parse_modes(config.get("run.modes")) name = args.name or C.default_name(config) root = C.run_path(ctx, name) + if not args.dry_run and (process := C.run_process(root)) is not None: + raise ConfigError(f"run {name} is already running (pid {process['pid']})") + if args.detach: + return _detach(args, ctx, name, root) suite = registry.load(config.get("suite.name")) split_file = (project / args.split_file).resolve() if args.split_file else None suite_ctx = registry.SuiteContext(config, project, root, split_file) @@ -228,6 +278,18 @@ def run(args, ctx) -> int: "split_file": str(split_file) if split_file else None, }, ) + pid_path = root / "run.pid" + pid_path.write_text( + json.dumps( + { + "pid": os.getpid(), + "started": datetime.now(timezone.utc).isoformat(), + "argv": args.argv, + } + ) + + "\n", + encoding="utf-8", + ) core.configure( **{ key: config.get(f"run.{key}") @@ -297,6 +359,8 @@ def run(args, ctx) -> int: "Run the same command again to retry.", file=sys.stderr, ) + if errors and args.json: + raise ConfigError(reason) ctx.out( {"result": result, "totals": events.totals(), "final": final}, lambda data: ( @@ -312,15 +376,27 @@ def run(args, ctx) -> int: except events.Stopped as exc: reason = str(exc) status = "interrupted" if reason == "interrupted" else "stopped" - ctx.out(events.totals(), lambda totals: _stopped(name, reason, totals)) + ctx.out( + {"error": "interrupted", "exit_code": 130} + if status == "interrupted" and args.json + else events.totals(), + lambda totals: _stopped(name, reason, totals), + ) return 130 if status == "interrupted" else 4 except KeyboardInterrupt: status, reason = "interrupted", "interrupted" events.request_stop(reason) - ctx.out(events.totals(), lambda totals: _stopped(name, reason, totals)) + ctx.out( + {"error": "interrupted", "exit_code": 130} if args.json else events.totals(), + lambda totals: _stopped(name, reason, totals), + ) return 130 except Exception as exc: reason = str(exc) raise finally: - events.emit("run_end", status=status, reason=reason) + try: + events.emit("run_end", status=status, reason=reason) + finally: + if pid_path is not None: + pid_path.unlink(missing_ok=True) diff --git a/harness2/cli/stop.py b/harness2/cli/stop.py new file mode 100644 index 00000000..4490d0fa --- /dev/null +++ b/harness2/cli/stop.py @@ -0,0 +1,59 @@ +# Copyright 2026 The Harness2 Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ask a run and its agent processes to stop.""" + +from __future__ import annotations + +import os +import signal + +from . import _common as C + + +def add_parser(subparsers): + p = subparsers.add_parser("stop", help="stop a running run; rerunning it resumes") + p.add_argument("name", nargs="?", help="run name (default: the most recent run)") + return p + + +def run(args, ctx) -> int: + root = C.resolve_run(ctx, args.name) + process = C.run_process(root) + if process is not None: + pid = process["pid"] + try: + # A detached run leads its own process group, so the agent CLIs it started stop + # with it. Otherwise the group can include whatever launched the run (a shell, an + # agent), so only the run itself is signalled. + if os.getpgid(pid) == pid: + os.killpg(pid, signal.SIGINT) + else: + os.kill(pid, signal.SIGINT) + except ProcessLookupError: + process = None + ctx.out( + { + "name": root.name, + "pid": process["pid"] if process else None, + "signalled": process is not None, + }, + lambda data: ( + f"Asked run {root.name} (pid {data['pid']}) to stop. It ends the calls in flight, " + "then exits; run the same command again to resume." + if data["signalled"] + else f"Run {root.name} is not running." + ), + ) + return 0 diff --git a/tests/test_agent_ops.py b/tests/test_agent_ops.py new file mode 100644 index 00000000..d34c04e2 --- /dev/null +++ b/tests/test_agent_ops.py @@ -0,0 +1,353 @@ +# Copyright 2026 The Harness2 Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Offline checks for machine output and run process controls.""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import signal +import subprocess +import sys +import tempfile +import unittest +from dataclasses import replace +from datetime import datetime +from pathlib import Path +from unittest.mock import Mock, patch + +from harness2.cli import _adopt, diff, main, run +from harness2.cli import _common as C +from harness2.core import events +from harness2.core.tree import diff_summary +from harness2.demo import DemoAdapter, DemoProposer + + +class AgentOpsTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.project = Path(temporary.name).resolve() + (self.project / ".harness2").mkdir() + env = patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.project / "config")}, clear=True) + env.start() + self.addCleanup(env.stop) + self.addCleanup(events.start, None) + self.root = self.project / ".harness2/runs/claude-k1" + + def make_adapter(ctx): + adapter = DemoAdapter(ctx.run_root / "fixture") + adapter.spec = replace(adapter.spec, runs_root=ctx.run_root) + return adapter + + self.suite = Mock(make_adapter=Mock(side_effect=make_adapter), check=Mock(return_value=[])) + registry = patch("harness2.registry.load", return_value=self.suite) + registry.start() + self.addCleanup(registry.stop) + + def invoke(self, *args): + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = main.main(["-C", str(self.project), *args]) + return code, stdout.getvalue(), stderr.getvalue() + + def pid_record(self, root=None): + root = root or self.root + root.mkdir(parents=True, exist_ok=True) + record = {"pid": os.getpid(), "started": "2026-10-19T00:00:00+00:00", "argv": ["run"]} + (root / "run.pid").write_text(json.dumps(record)) + return record + + def test_json_errors_and_text_behavior(self): + for exception in ( + OSError("io"), + RuntimeError("runtime"), + ValueError("value"), + KeyError("key"), + ): + with ( + self.subTest(exception=exception), + patch.object(diff, "run", side_effect=exception), + ): + code, out, err = self.invoke("--json", "--quiet", "diff") + self.assertEqual(code, 1) + self.assertEqual( + out.splitlines(), [json.dumps({"error": str(exception), "exit_code": 1})] + ) + self.assertEqual(err, "") + self.assertEqual(self.invoke("diff"), (1, "", f"error: {exception}\n")) + self.assertEqual(self.invoke("--quiet", "diff"), (1, "", "")) + with patch.object(diff, "run", side_effect=KeyboardInterrupt): + self.assertEqual(self.invoke("diff"), (130, "", "")) + code, out, err = self.invoke("--json", "diff") + self.assertEqual( + (code, json.loads(out), err), (130, {"error": "interrupted", "exit_code": 130}, "") + ) + + def test_argparse_errors_still_exit_two(self): + with ( + contextlib.redirect_stdout(io.StringIO()) as out, + contextlib.redirect_stderr(io.StringIO()) as err, + ): + with self.assertRaises(SystemExit) as raised: + main.main(["--json", "run", "--unknown"]) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(out.getvalue(), "") + self.assertIn("usage: harness2", err.getvalue()) + + def test_detach_preserves_in_process_argv_and_appends_logs(self): + child = Mock(pid=12345) + child.wait.side_effect = subprocess.TimeoutExpired("harness2", 2) + + def popen(argv, **kwargs): + self.assertEqual( + argv, + [ + sys.executable, + "-m", + "harness2", + "-C", + str(self.project), + "--json", + "run", + "--seed", + "0", + "--name", + "claude-k1-s0", + ], + ) + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) + self.assertEqual(kwargs["stderr"], subprocess.STDOUT) + self.assertTrue(kwargs["start_new_session"]) + self.assertNotIn("cwd", kwargs) + self.assertNotIn("env", kwargs) + kwargs["stdout"].write("child log\n") + return child + + root = self.root.with_name("claude-k1-s0") + root.mkdir(parents=True) + (root / "run.log").write_text("previous log\n") + with patch.object(run.subprocess, "Popen", side_effect=popen): + code, out, err = self.invoke("--json", "run", "--seed", "0", "--detach") + self.assertEqual( + (code, json.loads(out), err), + (0, {"name": root.name, "pid": 12345, "log": str(root / "run.log")}, ""), + ) + self.assertEqual((root / "run.log").read_text(), "previous log\nchild log\n") + child.wait.assert_called_once_with(timeout=2) + self.suite.make_adapter.assert_not_called() + self.assertFalse((root / "run.pid").exists()) + + def test_detach_explicit_name_and_exact_text(self): + child = Mock(pid=12345) + child.wait.return_value = 0 + with patch.object(run.subprocess, "Popen", return_value=child) as popen: + code, out, err = self.invoke("run", "--detach", "--name=chosen") + log = self.root.with_name("chosen") / "run.log" + self.assertEqual( + (code, out, err), + ( + 0, + f"Started run chosen in the background (pid 12345).\nFollow it: harness2 status chosen --watch\nStop it: harness2 stop chosen\nLog: {log}\n", + "", + ), + ) + self.assertEqual(popen.call_args.args[0][-2:], ["run", "--name=chosen"]) + + def test_detach_early_failure_reports_last_twenty_lines(self): + child = Mock(pid=12345) + child.wait.return_value = 7 + + def popen(argv, **kwargs): + kwargs["stdout"].write("".join(f"line {i}\n" for i in range(25))) + return child + + with patch.object(run.subprocess, "Popen", side_effect=popen): + code, out, err = self.invoke("--json", "run", "--detach") + self.assertEqual((code, out), (7, "")) + self.assertEqual(err.splitlines(), [f"line {i}" for i in range(5, 25)]) + + def test_detach_dry_run_is_rejected(self): + with patch.object(run.subprocess, "Popen") as popen: + code, out, _ = self.invoke("--json", "run", "--detach", "--dry-run") + self.assertEqual( + (code, json.loads(out)), + (1, {"error": "--detach starts a real run; drop --dry-run", "exit_code": 1}), + ) + popen.assert_not_called() + self.suite.make_adapter.assert_not_called() + + def test_already_running_refuses_both_start_modes(self): + record = self.pid_record() + for extra in ([], ["--detach"]): + with self.subTest(extra=extra), patch.object(run.subprocess, "Popen") as popen: + code, out, _ = self.invoke("--json", "run", *extra) + self.assertEqual( + (code, json.loads(out)), + ( + 1, + { + "error": f"run claude-k1 is already running (pid {record['pid']})", + "exit_code": 1, + }, + ), + ) + self.assertEqual(C.run_process(self.root), record) + popen.assert_not_called() + self.suite.make_adapter.assert_not_called() + + def test_run_process_live_stale_invalid_and_permission_denied(self): + self.assertIsNone(C.run_process(self.root)) + record = self.pid_record() + self.assertEqual(C.run_process(self.root), record) + with patch.object(C.os, "kill", side_effect=PermissionError): + self.assertEqual(C.run_process(self.root), record) + with patch.object(C.os, "kill", side_effect=ProcessLookupError): + self.assertIsNone(C.run_process(self.root)) + self.assertFalse((self.root / "run.pid").exists()) + for invalid in ( + "broken", + "[]", + "{}", + json.dumps(record | {"pid": 0}), + json.dumps(record | {"pid": True}), + json.dumps(record | {"argv": [1]}), + ): + with self.subTest(invalid=invalid), patch.object(C.os, "kill") as kill: + (self.root / "run.pid").write_text(invalid) + self.assertIsNone(C.run_process(self.root)) + self.assertFalse((self.root / "run.pid").exists()) + kill.assert_not_called() + + def test_pid_lifecycle_success_failure_and_interrupt(self): + argv = ["-C", str(self.project), "--json", "run"] + for exception, expected in ( + (None, 0), + (RuntimeError("crash"), 1), + (KeyboardInterrupt(), 130), + (events.Stopped("interrupted"), 130), + (events.Stopped("max_calls"), 4), + ): + + def sweep(*args, **kwargs): + record = C.run_process(self.root) + self.assertEqual(record["pid"], os.getpid()) + self.assertEqual(record["argv"], argv) + datetime.fromisoformat(record["started"]) + self.assertTrue((self.root / "run.json").is_file()) + if exception is not None: + raise exception + return {"domains": []} + + with ( + self.subTest(exception=exception), + patch.object(run.schedule, "run_sweep", side_effect=sweep), + patch.object(run, "Proposer", DemoProposer), + ): + code, out, _ = self.invoke("--json", "run") + self.assertEqual(code, expected) + self.assertFalse((self.root / "run.pid").exists()) + if expected in (1, 130): + self.assertEqual( + json.loads(out), + {"error": "crash" if expected == 1 else "interrupted", "exit_code": expected}, + ) + code, _, _ = self.invoke("run", "--dry-run") + self.assertEqual(code, 0) + self.assertFalse((self.root / "run.pid").exists()) + + def test_json_sweep_error(self): + with ( + patch.object( + run.schedule, "run_sweep", return_value={"domains": [{"error": "failed"}]} + ), + patch.object(run, "Proposer", DemoProposer), + ): + code, out, _ = self.invoke("--json", "run") + self.assertEqual((code, json.loads(out)), (1, {"error": "failed", "exit_code": 1})) + + def test_stop_signals_group_and_defaults_to_latest_run(self): + self.pid_record() + (self.root / "run.json").write_text("{}") + with ( + patch("harness2.cli.stop.os.getpgid", return_value=9876), + patch("harness2.cli.stop.os.killpg") as killpg, + patch("harness2.cli.stop.os.kill") as kill, + ): + self.assertEqual(self.invoke("stop")[0], 0) + killpg.assert_not_called() # not a group leader: the launcher's group is left alone + kill.assert_any_call(os.getpid(), signal.SIGINT) + for json_flag in ([], ["--json"]): + with ( + patch("harness2.cli.stop.os.getpgid", return_value=os.getpid()) as getpgid, + patch("harness2.cli.stop.os.killpg") as killpg, + ): + code, out, err = self.invoke(*json_flag, "stop") + self.assertEqual((code, err), (0, "")) + getpgid.assert_called_once_with(os.getpid()) + killpg.assert_called_once_with(os.getpid(), signal.SIGINT) + if json_flag: + self.assertEqual( + json.loads(out), {"name": self.root.name, "pid": os.getpid(), "signalled": True} + ) + else: + self.assertEqual( + out, + f"Asked run claude-k1 (pid {os.getpid()}) to stop. It ends the calls in flight, then exits; run the same command again to resume.\n", + ) + + def test_stop_not_running_and_process_exit_race(self): + self.root.mkdir(parents=True) + (self.root / "run.json").write_text("{}") + self.assertEqual( + self.invoke("stop", self.root.name), (0, "Run claude-k1 is not running.\n", "") + ) + code, out, err = self.invoke("--json", "stop") + self.assertEqual( + (code, json.loads(out), err), + (0, {"name": self.root.name, "pid": None, "signalled": False}, ""), + ) + self.pid_record() + with patch("harness2.cli.stop.os.getpgid", side_effect=ProcessLookupError): + self.assertEqual(self.invoke("stop"), (0, "Run claude-k1 is not running.\n", "")) + + def test_diff_json_and_unchanged_text(self): + before = {"a": "old\n", "d": "removed\n"} + after = {"a": "new\n", "b": "added\n"} + adapter = DemoAdapter(self.project / "diff-fixture") + changes = [["a", "M"], ["b", "A"], ["d", "D"]] + diffs = {rel: _adopt.unified(rel, before, after) for rel, _ in changes} + with ( + patch.object(_adopt, "open_run", return_value=(adapter, self.root)), + patch.object(_adopt, "harness_for", return_value=("task-1", after)), + patch.object(_adopt, "base_tree", return_value=before), + ): + for extra in ([], ["--stat"]): + code, out, err = self.invoke("--json", "diff", *extra) + self.assertEqual( + (code, json.loads(out), err), + (0, {"label": "task-1", "changes": changes, "diffs": diffs}, ""), + ) + self.assertEqual(self.invoke("diff"), (0, "".join(diffs.values()), "")) + self.assertEqual( + self.invoke("diff", "--stat"), + (0, str(diff_summary(adapter.substrate.surface(), before, after)) + "\n", ""), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 5d0bc03a..7019bbc0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -312,14 +312,14 @@ def test_demo_backed_run_judge_report_diff_for_each_scale(self): code, stdout, _ = self.invoke("--json", "diff", name) self.assertEqual(code, 0) diff = json.loads(stdout) - self.assertEqual(diff["task"], "consolidated") - self.assertIn("-total = sum", diff["diff"]) - self.assertIn("+total = sum", diff["diff"]) + self.assertEqual(diff["label"], "consolidated") + self.assertIn("-total = sum", diff["diffs"]["scripts/summarize.py"]) + self.assertIn("+total = sum", diff["diffs"]["scripts/summarize.py"]) code, stdout, _ = self.invoke( "--json", "diff", name, "--task", "ledger-1", "--mode", "sequential", "--stat" ) self.assertEqual(code, 0) - self.assertIn("scripts/summarize.py", json.loads(stdout)["modified"]) + self.assertIn(["scripts/summarize.py", "M"], json.loads(stdout)["changes"]) self.assertEqual(self.invoke("diff", name, "--task", "missing")[0], 1) self.assertEqual(self.invoke("--json", "judge", name, "--bases")[0], 0) code, stdout, _ = self.invoke("--json", "runs", "show", name) diff --git a/tests/test_consolidate.py b/tests/test_consolidate.py index 9897723f..fc7589d7 100644 --- a/tests/test_consolidate.py +++ b/tests/test_consolidate.py @@ -207,11 +207,11 @@ def test_two_tasks_retry_adoption_eval_and_json(self): self.assertEqual(self.calls.read_text(), "2") self.assertTrue(list((self.run_dir / "consolidated/_audit").glob("*.response.md"))) consolidated = json.loads(self.invoke("--json", "diff", "trial")[0]) - self.assertEqual(consolidated["task"], "consolidated") - self.assertIn("+Use shared procedure.", consolidated["diff"]) + self.assertEqual(consolidated["label"], "consolidated") + self.assertIn("+Use shared procedure.", consolidated["diffs"]["CLAUDE.md"]) task = json.loads(self.invoke("--json", "diff", "trial", "--task", "a-one")[0]) - self.assertEqual(task["task"], "a-one") - self.assertIn("+Task note.", task["diff"]) + self.assertEqual(task["label"], "a-one") + self.assertIn("+Task note.", task["diffs"]["CLAUDE.md"]) applied, _ = self.invoke("apply", "trial", "--dry-run", "--task", "a-one") self.assertIn("Harness adopted for a-one (parallel, run trial)", applied) applied, _ = self.invoke("apply", "trial", "--yes")