Skip to content

Commit ccd69e2

Browse files
deimagjasdeimagjasclaude
authored
Feat/apply modularity review (#12)
* test(cli): add opt-in end-to-end orchestrator<->agent tests Add real-container E2E tests that validate the full round-trip between the orchestrator (the q CLI) and agents running in Apple containers: - test_claude_agent_e2e.py: spawn a Claude agent, then assert status.json, the persisted log, the [agent:status] markers and the worktree commit all reach the orchestrator. - test_pi_agent_e2e.py: the same round-trip for a PI agent (local mlx_lm backend), asserting the report is tagged agent_kind=pi. Tests are gated by STACKAI_E2E=1 via collect_ignore_glob, so the default pytest run and the mutmut suite never collect them — they require Apple Container CLI and are local-only, like the acceptance suite. Adds the `e2e` marker and a `make e2e-test` target (excluded from CI and local-qa). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(cli): centralise Makefile target names in a contract module The CLI's knowledge of which config/Makefile targets exist was scattered across five command modules as bare string literals, with nothing to catch a renamed target until a user hit the failure at runtime. Add src/container_cli/targets.py — a Target StrEnum that is the single source of truth for the 19 targets the CLI invokes. The command modules now reference Target.SPAWN etc.; run_make is typed against Target. Because StrEnum members equal their string value, every existing assertion stays green unchanged. tests/test_targets.py adds a contract test that parses config/Makefile and fails if any Target member is missing — a rename is now caught in CI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(cli): consolidate worktree and status logic in utils.py _agents_home() was duplicated byte-for-byte in agents.py and pi_agents.py, and the status command bodies were near-identical copies that also knew the private .agent/status.json layout. run/shell shared an identical body too. - Add utils.agents_home() — the single definition of the worktree-root rule (was duplicated 2x in Python). - Add utils.print_agent_status(branch, label=...) — the single reader of .agent/status.json; agents.status and pi.status now delegate to it, keeping their [status] / [pi-status] message tags via the label arg. - Extract run._coordinator() so run and shell stop duplicating their body. Behaviour is unchanged: status still reads the file directly and still exits 1 when it is missing, so no .feature file changes. The acceptance conftest now patches find_git_root in utils (one patch instead of two), and the moved TestAgentsHome tests live in test_utils.py alongside new coverage for print_agent_status. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(cli): drop the dead Typer apps from build/network/run build.py, network.py and run.py each created a typer.Typer() app and decorated their functions with @app.command(), but main.py never mounted those apps — it registers the bare functions directly. The app objects were dead code that misled readers into expecting a mounted sub-app. Remove the unused typer.Typer() and @app.command() decorators from the three modules; their functions are now plain `def`s, registered by main.py exactly as before. agents.py and pi_agents.py keep their Typer app because they genuinely are mounted as sub-apps — that becomes the one consistent pattern. The q CLI surface is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cli): locate config/Makefile by walking up in the contract test test_targets.py resolved the repo root with a fixed parent index, which pointed at the wrong directory when the test ran from the mutmut copy under mutants/ (one extra path level). Walk up from the test file until a config/Makefile is found instead — correct both in tests/ and mutants/tests/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: document the E2E tests and refresh the CLI reference - Add docs/agents/e2e-tests.md: prerequisites, how to run `make e2e-test`, what each round-trip test asserts, and the cost/CI caveats. - cli.md: add targets.py to the package structure, note the Target contract and the tests/e2e/ suite. - CLAUDE.md: list `make e2e-test` in the Testing section. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * add: modularity review * feat(agents): add --model flag (default opus), persist lifecycle markers, bump bundled claude CLI Add a --model option to `q spawn` so the orchestrator governs the headless agent's Claude model instead of inheriting the host's interactive ~/.claude/settings.json preference (which can belong to a different account than the container's OAuth token). The flag flows CLI -> Makefile (MODEL ?= opus) -> -e AGENT_MODEL -> entrypoint (`claude --model "$AGENT_MODEL"`), default opus. Fixes a second bug that the original failure masked: emit_marker() echoed [agent:status] events only to the container's stdout, so once the --rm container was gone `q agents summary` fell back to agent.log and printed "(no structured events found)". emit_marker now also appends to agent.log (group redirect so the open error is suppressed cleanly in the shellspec mock env), and tee -a preserves the early starting/working markers across the claude run. Bumps the bundled claude CLI in the Wolfi image. The previous version (2.1.138) had a bug rejecting Opus with `400 role 'system' is not supported on this model`. The Dockerfile now ends the install RUN with `&& claude --version`, both as a build-time trace and as a cache-busting tail so a plain rebuild picks up newer CLIs without forcing --no-cache. Tests and docs: - new acceptance scenario for `q spawn --model opus` - unit tests for MODEL presence/absence in make_vars - shellspec asserts default opus and AGENT_MODEL=sonnet override - SKILL.md, docs/agents/{cli,container-agent,spawn-agent-skill}.md Gates after rebuild to claude 2.1.149: ruff, 100 unit/acceptance, 59 shellspec, mutation 95.2%, and make e2e-test (Claude round-trip) all green; PI e2e remains opt-in (local mlx server). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(e2e): opt-in mlx_lm.server autostart fixture for the PI round-trip Add a session-scoped fixture in tests/e2e/conftest.py that — when STACKAI_E2E_AUTOSTART_MLX=1 is exported — spawns mlx_lm.server itself, waits for /v1/models to respond, yields to the tests, and tears the subprocess down on session teardown. Without the env var, or when a server is already reachable on the URL, the fixture is a no-op (a manually-started server is honoured and not touched). The invocation is the exact set of flags the PI agent expects (model mlx-community/gemma-4-26b-a4b-it-4bit, port 8080, --temp 0.9, --top-p 0.95, 6 GB prompt cache, etc.) — no coupling to the iac CLI: the e2e suite must run standalone. Boot timeout is configurable via STACKAI_E2E_MLX_BOOT_TIMEOUT (default 600s) to absorb first-run model downloads, and the subprocess log lands in $TMPDIR/stackai-e2e-mlx.log for diagnosis. Verified end-to-end on a warm cache: both the Claude and PI round-trips pass in ~33s. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: deimagjas <deimagjas@127.0.0.1> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f2a2fa8 commit ccd69e2

31 files changed

Lines changed: 1483 additions & 141 deletions

.claude/skills/spawn-agent/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,17 @@ PROJECT_NAME=$(basename "$GIT_ROOT") # e.g. stackai
4646
WORKTREES_DIR="${AGENTS_HOME}" # from env var, e.g. ~/agents
4747
NETWORK=claude-agent-net
4848
IMAGE=claude-agent:wolfi
49+
AGENT_MODEL=opus # model the headless agent runs
4950
```
5051

52+
**Agent model.** The headless agent runs whatever `AGENT_MODEL` is passed via
53+
`-e AGENT_MODEL`; it defaults to `opus` so the agent runs with the most capable
54+
model out of the box. Do **not** rely on the host's `~/.claude/settings.json`
55+
`model` preference — it is copied into the container but a headless agent must
56+
not inherit a personal interactive setting, and the container's OAuth token may
57+
belong to a different plan. Override per spawn when a lighter task warrants it
58+
(`q spawn --model sonnet`, or `-e AGENT_MODEL=sonnet` on a raw `container run`).
59+
5160
Container names follow the pattern: `<project>-<sanitized-branch>`
5261
Branch sanitization — each `/`, `_`, or space becomes a single `-`, lowercased:
5362
```bash
@@ -143,6 +152,7 @@ container run -d --rm \
143152
-v "${HOME}/.claude:/root/.claudenew:ro" \
144153
-v "${HOME}/.claude.json:/root/.claudenew.json:ro" \
145154
-e CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
155+
-e "AGENT_MODEL=${AGENT_MODEL:-opus}" \
146156
-e "CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CONTAINER_OAUTH_TOKEN}" \
147157
claude-agent:wolfi \
148158
--worktree "${BRANCH}" --task "${TASK}"
@@ -405,6 +415,8 @@ Full docs: https://github.com/apple/container/blob/main/docs/command-reference.m
405415
the container exits so you can review the agent's work.
406416
- **CLAUDE_CONTAINER_OAUTH_TOKEN** must be set — containers authenticate with this,
407417
not the host Claude session.
418+
- **AGENT_MODEL** controls the agent's Claude model (`opus` by default). The agent
419+
does not inherit the host's `settings.json` model preference — pass it explicitly.
408420
- The image `claude-agent:wolfi` must exist. If not: `cd <git-root>/config && make build`
409421
- Multiple agents run in parallel — each gets a unique container + worktree.
410422
- Branch names with `/` (e.g., `feat/auth`) are valid for git; sanitized for container

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ uv run pytest -k test_spawn # single test by name
5353
# Acceptance tests only (Gherkin/BDD, local — no real containers)
5454
make acceptance-test
5555

56+
# End-to-end tests (real containers, opt-in, local-only — see docs/agents/e2e-tests.md)
57+
make e2e-test
58+
5659
# Mutation testing gate (≥ 70% kill rate)
5760
make mutation-ci-threshold
5861

app/cli/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ test-all:
4242
eval-skills:
4343
claude -p "/skill-creator:skill-creator run evals for the spawn-agent skill at ~/.claude/skills/spawn-agent/"
4444

45+
# End-to-end container tests — opt-in, local-only (needs Apple Container CLI).
46+
# Spawns real agent containers; the Claude round-trip spends Claude credits.
47+
# Excluded from CI and from `local-qa` by design.
48+
.PHONY: e2e-test
49+
e2e-test:
50+
STACKAI_E2E=1 uv run pytest tests/e2e -v -m e2e
51+
4552
# Pre-PR quality gate
4653
local-qa: acceptance-test eval-skills
4754

app/cli/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ dev = [
2525
[tool.pytest.ini_options]
2626
testpaths = ["tests", "tests/acceptance"]
2727
python_files = ["test_*.py", "*_steps.py"]
28+
markers = [
29+
"e2e: real-container end-to-end tests (opt-in via STACKAI_E2E=1, local-only)",
30+
]
2831

2932
[tool.mutmut]
3033
paths_to_mutate = ["src/container_cli/"]
Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,26 @@
11
"""Agent lifecycle commands (spawn, list, logs, follow, stop, status, summary)."""
22

3-
import json
4-
import os
5-
from pathlib import Path
63
from typing import Annotated
74

85
import typer
96

10-
from container_cli.utils import check_token, find_git_root, run_make
7+
from container_cli.targets import Target
8+
from container_cli.utils import check_token, print_agent_status, run_make
119

1210
app = typer.Typer(help="Agent lifecycle commands")
1311

1412

15-
def _agents_home() -> Path:
16-
"""Resolve AGENTS_HOME, falling back to sibling .worktrees/ directory."""
17-
env_val = os.environ.get("AGENTS_HOME")
18-
if env_val:
19-
return Path(env_val)
20-
return find_git_root().parent / ".worktrees"
21-
22-
2313
@app.command()
2414
def spawn(
2515
branch: Annotated[str, typer.Option("--branch", help="Git branch for the agent worktree")],
2616
task: Annotated[str, typer.Option("--task", help="Task description for the agent")],
2717
cpus: Annotated[int | None, typer.Option("--cpus", help="CPU count")] = None,
2818
memory: Annotated[str | None, typer.Option("--memory", help="Memory limit (e.g. 12G)")] = None,
2919
image: Annotated[str | None, typer.Option("--image", help="Image tag")] = None,
20+
model: Annotated[
21+
str | None,
22+
typer.Option("--model", help="Claude model the agent runs (e.g. sonnet, opus, haiku)"),
23+
] = None,
3024
) -> None:
3125
"""Spawn a detached headless agent container."""
3226
check_token()
@@ -37,57 +31,52 @@ def spawn(
3731
make_vars["MEMORY"] = memory
3832
if image:
3933
make_vars["IMAGE"] = image
40-
run_make("spawn", make_vars)
34+
if model:
35+
make_vars["MODEL"] = model
36+
run_make(Target.SPAWN, make_vars)
4137

4238

4339
@app.command(name="list")
4440
def list_agents() -> None:
4541
"""List active agent containers and worktrees."""
46-
run_make("list-agents")
42+
run_make(Target.LIST_AGENTS)
4743

4844

4945
@app.command()
5046
def logs(
5147
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
5248
) -> None:
5349
"""Show logs for a branch agent."""
54-
run_make("logs-agent", {"BRANCH": branch})
50+
run_make(Target.LOGS_AGENT, {"BRANCH": branch})
5551

5652

5753
@app.command()
5854
def follow(
5955
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
6056
) -> None:
6157
"""Follow live streaming logs for a branch agent."""
62-
run_make("follow-agent", {"BRANCH": branch}, tty=True)
58+
run_make(Target.FOLLOW_AGENT, {"BRANCH": branch}, tty=True)
6359

6460

6561
@app.command()
6662
def stop(
6763
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
6864
) -> None:
6965
"""Stop a branch agent container."""
70-
run_make("stop-agent", {"BRANCH": branch})
66+
run_make(Target.STOP_AGENT, {"BRANCH": branch})
7167

7268

7369
@app.command()
7470
def status(
7571
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
7672
) -> None:
7773
"""Show agent status from persisted status.json file."""
78-
status_file = _agents_home() / branch / ".agent" / "status.json"
79-
if not status_file.exists():
80-
typer.echo(f"[status] No status file found for branch '{branch}'.")
81-
typer.echo(f"[status] Expected at: {status_file}")
82-
raise typer.Exit(1)
83-
84-
data = json.loads(status_file.read_text())
85-
typer.echo(json.dumps(data, indent=2))
74+
print_agent_status(branch, label="status")
8675

8776

8877
@app.command()
8978
def summary(
9079
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
9180
) -> None:
9281
"""Show structured lifecycle events for a branch agent."""
93-
run_make("summary-agent", {"BRANCH": branch})
82+
run_make(Target.SUMMARY_AGENT, {"BRANCH": branch})
Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
"""Image build and cleanup commands."""
1+
"""Image build and cleanup commands.
2+
3+
These functions are registered as top-level commands by `container_cli.main`.
4+
"""
25

36
from typing import Annotated
47

58
import typer
69

10+
from container_cli.targets import Target
711
from container_cli.utils import run_make
812

9-
app = typer.Typer(help="Image build commands")
10-
1113

12-
@app.command()
1314
def build(
1415
image: Annotated[str | None, typer.Option("--image", help="Image tag")] = None,
1516
dockerfile: Annotated[str | None, typer.Option("--dockerfile", help="Dockerfile path")] = None,
@@ -20,22 +21,19 @@ def build(
2021
make_vars["IMAGE"] = image
2122
if dockerfile:
2223
make_vars["DOCKERFILE"] = dockerfile
23-
run_make("build", make_vars)
24+
run_make(Target.BUILD, make_vars)
2425

2526

26-
@app.command()
2727
def clean() -> None:
2828
"""Remove the container image."""
29-
run_make("clean")
29+
run_make(Target.CLEAN)
3030

3131

32-
@app.command(name="clean-network")
3332
def clean_network() -> None:
3433
"""Remove the bridge network."""
35-
run_make("clean-network")
34+
run_make(Target.CLEAN_NETWORK)
3635

3736

38-
@app.command(name="clean-all")
3937
def clean_all() -> None:
4038
"""Remove image and network."""
41-
run_make("clean-all")
39+
run_make(Target.CLEAN_ALL)
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
"""Bridge network management commands."""
1+
"""Bridge network management command.
2+
3+
Registered as a top-level command by `container_cli.main`.
4+
"""
25

36
from typing import Annotated
47

58
import typer
69

10+
from container_cli.targets import Target
711
from container_cli.utils import run_make
812

9-
app = typer.Typer(help="Network management commands")
10-
1113

12-
@app.command()
1314
def network(
1415
subnet: Annotated[str | None, typer.Option("--subnet", help="Subnet CIDR")] = None,
1516
network_name: Annotated[str | None, typer.Option("--network-name", help="Network name")] = None,
@@ -20,4 +21,4 @@ def network(
2021
make_vars["SUBNET"] = subnet
2122
if network_name:
2223
make_vars["NETWORK"] = network_name
23-
run_make("network", make_vars)
24+
run_make(Target.NETWORK, make_vars)

app/cli/src/container_cli/commands/pi_agents.py

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,16 @@
1010

1111
from __future__ import annotations
1212

13-
import json
14-
import os
15-
from pathlib import Path
1613
from typing import Annotated
1714

1815
import typer
1916

20-
from container_cli.utils import find_git_root, run_make
17+
from container_cli.targets import Target
18+
from container_cli.utils import print_agent_status, run_make
2119

2220
app = typer.Typer(help="PI agent lifecycle (local mlx_lm.server backend)")
2321

2422

25-
def _agents_home() -> Path:
26-
"""Resolve AGENTS_HOME, falling back to sibling .worktrees/ directory."""
27-
env_val = os.environ.get("AGENTS_HOME")
28-
if env_val:
29-
return Path(env_val)
30-
return find_git_root().parent / ".worktrees"
31-
32-
3323
@app.command()
3424
def build(
3525
image: Annotated[str | None, typer.Option("--image", help="PI image tag")] = None,
@@ -43,7 +33,7 @@ def build(
4333
make_vars["PI_IMAGE"] = image
4434
if dockerfile:
4535
make_vars["PI_DOCKERFILE"] = dockerfile
46-
run_make("build-pi", make_vars)
36+
run_make(Target.BUILD_PI, make_vars)
4737

4838

4939
@app.command()
@@ -87,48 +77,42 @@ def spawn(
8777
make_vars["PI_BASE_URL"] = base_url
8878
if model_id:
8979
make_vars["PI_MODEL_ID"] = model_id
90-
run_make("spawn-pi", make_vars)
80+
run_make(Target.SPAWN_PI, make_vars)
9181

9282

9383
@app.command(name="list")
9484
def list_agents() -> None:
9585
"""List active PI agent containers and PI worktrees."""
96-
run_make("list-pi-agents")
86+
run_make(Target.LIST_PI_AGENTS)
9787

9888

9989
@app.command()
10090
def logs(
10191
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
10292
) -> None:
10393
"""Show logs for a PI agent (live container or persisted log)."""
104-
run_make("logs-pi-agent", {"BRANCH": branch})
94+
run_make(Target.LOGS_PI_AGENT, {"BRANCH": branch})
10595

10696

10797
@app.command()
10898
def follow(
10999
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
110100
) -> None:
111101
"""Follow live streaming logs for a PI agent."""
112-
run_make("follow-pi-agent", {"BRANCH": branch}, tty=True)
102+
run_make(Target.FOLLOW_PI_AGENT, {"BRANCH": branch}, tty=True)
113103

114104

115105
@app.command()
116106
def stop(
117107
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
118108
) -> None:
119109
"""Stop a PI agent container."""
120-
run_make("stop-pi-agent", {"BRANCH": branch})
110+
run_make(Target.STOP_PI_AGENT, {"BRANCH": branch})
121111

122112

123113
@app.command()
124114
def status(
125115
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
126116
) -> None:
127117
"""Show PI agent status from persisted status.json file."""
128-
status_file = _agents_home() / branch / ".agent" / "status.json"
129-
if not status_file.exists():
130-
typer.echo(f"[pi-status] No status file found for branch '{branch}'.")
131-
typer.echo(f"[pi-status] Expected at: {status_file}")
132-
raise typer.Exit(1)
133-
data = json.loads(status_file.read_text())
134-
typer.echo(json.dumps(data, indent=2))
118+
print_agent_status(branch, label="pi-status")

0 commit comments

Comments
 (0)