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
28 changes: 28 additions & 0 deletions harness2/cli/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
4 changes: 4 additions & 0 deletions harness2/cli/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions harness2/cli/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 13 additions & 10 deletions harness2/cli/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion harness2/cli/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> [--files] [--check "<cmd>"]`, then write the request in
.harness2/tasks/<id>/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
Expand Down
9 changes: 8 additions & 1 deletion harness2/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"task",
"run",
"status",
"stop",
"judge",
"report",
"runs",
Expand Down Expand Up @@ -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


Expand Down
82 changes: 79 additions & 3 deletions harness2/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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: (
Expand All @@ -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)
59 changes: 59 additions & 0 deletions harness2/cli/stop.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading