diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index 054b25b..1a20fba 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -11,6 +11,12 @@ # Install dependencies uv sync --extra dev +# Install/upgrade the user command +make install + +# Start the operator alpha session +tamq start --command codex railiance-platform activity-core + # Run tests uv run pytest diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml index e918d72..bef92ff 100644 --- a/.forgejo/workflows/ci-smoke.yaml +++ b/.forgejo/workflows/ci-smoke.yaml @@ -12,6 +12,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" + - run: sudo apt-get update + - run: sudo apt-get install -y tmux + - run: python -m pip install uv - run: python -m pip install -e '.[dev]' - run: pytest -q - run: python -m tamq.cli --help diff --git a/AGENTS.md b/AGENTS.md index 9750115..6b34e06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,6 +266,10 @@ To create a new workplan: ## Repository-specific workflow - Install development dependencies with `uv sync --extra dev`. +- Install or upgrade the user command with `make install`. +- The operator alpha acceptance command is + `tamq start --command codex railiance-platform activity-core`; it must open + both exact gita paths and launch Codex once in each new window. - Run the test suite with `uv run pytest` or `make test`. - Before handoff, run `make check`; it combines tests, whitespace validation, and Python bytecode compilation. diff --git a/Makefile b/Makefile index 997767f..21f35b2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,13 @@ -.PHONY: test check +.PHONY: install uninstall test check + +install: + @command -v uv >/dev/null || { echo "tamq: uv is required; install it from https://docs.astral.sh/uv/" >&2; exit 1; } + uv tool install --force --reinstall --refresh . + @command -v tamq >/dev/null || { echo "tamq: installed, but the uv tool bin directory is not on PATH; run 'uv tool update-shell'" >&2; exit 1; } + tamq --version + +uninstall: + uv tool uninstall tmux-amq test: uv run pytest diff --git a/README.md b/README.md index 185466a..f5bd23c 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,62 @@ Tmux Agentic Message Queueing (`tamq`) is a local, durable message queue for agent workers running in gita-registered repositories. -The initial implementation is being bootstrapped from the standalone queue and -CLI contract. It will provide tmux endpoint lifecycle, local SQLite history and -leases, direct `@repo:` routing, JSONL export/replay, and a Unix-socket attach -protocol for a later coordination-engine adapter. +The local alpha provides tmux endpoint lifecycle, local SQLite history and +leases, direct `@repo:` routing, JSONL export/replay, and a Unix-socket protocol +for a later coordination-engine adapter. + +## Install and start a local session + +Prerequisites must be available on `PATH`: Python 3.11+, [uv](https://docs.astral.sh/uv/), +tmux, gita, and the agent command you want to run (`codex` by default). Each +repository must already have an exact gita slug and path. + +Install or upgrade `tamq` as an isolated user tool: + +```bash +make install +command -v tamq +tamq --version +``` + +Start an attached two-repository Codex session: + +```bash +tamq start --command codex railiance-platform activity-core +``` + +This creates or reuses the managed `tamq` session, opens same-named windows in +the exact gita paths for `railiance-platform` and `activity-core`, launches Codex +behind `tamq tap` in each new window, and attaches to the first window. Codex is +the default, so this is equivalent: + +```bash +tamq start railiance-platform activity-core +``` + +Set up the session without attaching, then attach later: + +```bash +tamq start --detach --command codex railiance-platform activity-core +tamq status +tamq attach +``` + +Repeated starts reuse existing windows and do not launch a second agent in +them. `--cmd` remains an alias for `--command`. A quoted command may include +arguments, for example `--command 'codex --profile local'`. + +For degraded tmux-only use, `tamq start --no-service ...` skips socket endpoint +registration and message delivery. Stop the broker and remove the managed tmux +session explicitly when finished: + +```bash +tamq stop +tmux kill-session -t tamq +``` + +Uninstall the user tool from the checkout with `make uninstall`, or from +anywhere with `uv tool uninstall tmux-amq`. ## Development @@ -18,7 +70,7 @@ uv run tamq start --cmd codex net-kingdom railiance-platform uv run tamq attach ``` -The bootstrap currently includes the local SQLite queue and a tmux endpoint +The alpha includes the local SQLite queue and a tmux endpoint manager, strict gita target validation, and a control-mode client for tmux operations/injection, and the PTY tap for full-duplex input observation. The control-mode client alone does not expose arbitrary pane input; `tamq tap` is @@ -63,10 +115,13 @@ safety_gated_max_attempts = 2 delivery_ack_mode = "acknowledged" ``` -Retries remain safety-gated and capped at nine attempts. Use `--policy-profile` -to select a profile; `--orwell` enables explicitly unsafe local diagnostics. +Policy profiles accept a safety-gated retry cap of at most nine attempts, but +the alpha delivery path does not enforce attempt counting yet. Explicit +acknowledgement exists, while `delivery_ack_mode` enforcement and bounded retry +state are tracked in `TAMQ-WP-0003`. Use `--policy-profile` to select a profile; +`--orwell` enables explicitly unsafe local diagnostics. -The Unix socket service now supports structured `ping`, `register`, `send`, and -`history` operations. Endpoint registrations and messages are persisted in the -same local SQLite database; delivery remains delegated to the control-mode -adapter. +The Unix socket service supports structured `ping`, `register`, `send`, +`history`, `ack`, `endpoints`, and `disconnect` operations. Endpoint +registrations and messages are persisted in the same local SQLite database; +delivery remains delegated to the control-mode adapter. diff --git a/SCOPE.md b/SCOPE.md index d9c1c3a..dd71a91 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -42,7 +42,7 @@ transport. | Direct repository addressing | Implemented | Exact `gita` validation and `@repo:` parsing are covered by tests. | | Durable, inspectable local queue | Implemented | SQLite history, leases, endpoint records, inspect/history, JSONL export/replay, acknowledgement, and purge are present. | | Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. | -| Tmux endpoint lifecycle and delivery | Implemented, integration confidence limited | The manager, control client, and delivery loop exist, but tests use fakes and CI does not exercise real tmux panes or agent processes. | +| Tmux endpoint lifecycle and delivery | Implemented for local alpha | Real isolated-tmux and installed-package tests prove exact repository windows, stable reuse, control-mode injection, durable history transition, service stop, and bounded cleanup. | | Full-duplex input observation | Implemented, lightly proven | `tamq tap` preserves PTY traffic and observes complete address lines; PTY code coverage is 23%. | | Bounded retry behavior | Not enforced | Failed injection remains pending and becomes claimable after lease expiry, but no attempt counter or terminal failure state applies the configured cap. | | Acknowledgement policy | Partially implemented | Explicit acknowledgement and the configuration field exist; delivery always marks a successful tmux injection as `injected`, irrespective of `delivery_ack_mode`. | @@ -50,15 +50,19 @@ transport. ## Practical Usability -The following operator path was exercised successfully on 2026-08-24 with an -isolated state directory: start the socket service, ping it, inspect status, -queue a message to registered repo `tmux-amq`, inspect history, export one JSONL -record, and stop the service. +The installable alpha path was exercised successfully on 2026-08-24 with both +isolated fixtures and the operator's registered repositories. `make install` +produced a user-level `tamq` command; `tamq start --detach --command codex +railiance-platform activity-core` created two same-named live panes rooted at +their exact gita paths. Repeating the command retained the same session instance +and pane PIDs. The automated installed-package path also proved message +injection, history, status, service stop, and cleanup. Suitable today: - Local queue, history, export/replay, and diagnostic use. -- Controlled experiments with gita-registered repositories and tmux endpoints. +- Interactive local Codex sessions over one or more gita-registered repositories. +- Controlled message-routing experiments between managed tmux endpoints. - Developing and testing the future coordination-engine adapter against the local socket boundary. @@ -66,20 +70,20 @@ Not yet suitable: - Unattended or high-confidence delivery where bounded retries and positive recipient acknowledgement are required. -- Production-style operation without a real tmux/PTY integration test suite, - crash-recovery evidence, and stronger process supervision evidence. +- Production-style operation without full PTY lifecycle, crash-recovery, and + stronger process-supervision evidence. - Cross-host messaging or use as a general-purpose broker. -The suite currently has 50 passing tests and 73% statement coverage. Coverage +The suite currently has 63 passing tests and 75% statement coverage. Coverage is strongest in durable storage and registry handling, and weakest in the PTY -tap, control-mode process handling, and CLI orchestration. The Forgejo CI job -runs unit tests and CLI help/version smoke checks on Python 3.11, but does not -install or exercise tmux and gita end to end. +tap and CLI orchestration. The Forgejo CI job installs tmux and uv, runs the +real-tmux and isolated installed-package session tests with a deterministic gita +fixture, and retains CLI help/version smoke checks on Python 3.11. ## Next Usability Gates -- `TAMQ-WP-0003` owns bounded retry state, acknowledgement enforcement, - lifecycle recovery, and real tmux/PTY integration evidence. +- `TAMQ-WP-0003` owns bounded retry state, acknowledgement enforcement, crash + recovery, and deeper PTY lifecycle evidence. - `TAMQ-WP-0002` owns the coordination-engine adapter after the local delivery contract is sufficiently reliable. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 0d9d301..d67c7ec 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,7 +12,7 @@ | workplan | TAMQ-WP-0001 | finished | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md | | workplan | TAMQ-WP-0002 | active | — | workplans/TAMQ-WP-0002-coordination-engine-adapter.md | | workplan | TAMQ-WP-0003 | active | — | workplans/TAMQ-WP-0003-delivery-reliability.md | -| workplan | TAMQ-WP-0004 | ready | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | +| workplan | TAMQ-WP-0004 | finished | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | | task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md | | task | TAMQ-WP-0001-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md | | task | TAMQ-WP-0001-T02 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md | @@ -24,8 +24,8 @@ | task | TAMQ-WP-0003-T02 | wait | — | workplans/TAMQ-WP-0003-delivery-reliability.md | | task | TAMQ-WP-0003-T03 | todo | — | workplans/TAMQ-WP-0003-delivery-reliability.md | | task | TAMQ-WP-0003-T04 | wait | — | workplans/TAMQ-WP-0003-delivery-reliability.md | -| task | TAMQ-WP-0004-T01 | todo | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | -| task | TAMQ-WP-0004-T02 | todo | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | -| task | TAMQ-WP-0004-T03 | todo | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | -| task | TAMQ-WP-0004-T04 | wait | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | -| task | TAMQ-WP-0004-T05 | wait | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | +| task | TAMQ-WP-0004-T01 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | +| task | TAMQ-WP-0004-T02 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | +| task | TAMQ-WP-0004-T03 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | +| task | TAMQ-WP-0004-T04 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | +| task | TAMQ-WP-0004-T05 | done | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | diff --git a/src/tamq/cli.py b/src/tamq/cli.py index 9197c3d..a40ce7e 100644 --- a/src/tamq/cli.py +++ b/src/tamq/cli.py @@ -67,7 +67,21 @@ def ensure_service() -> bool: if asyncio.run(ping()): return True time.sleep(0.05) - return False + return False + + +def preflight_runtime_paths() -> None: + """Ensure local state/runtime parents are available before tmux mutation.""" + parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent} + for parent in parents: + parent.mkdir(parents=True, exist_ok=True) + if not os.access(parent, os.W_OK | os.X_OK): + raise OSError(f"runtime directory is not writable: {parent}") + + +def attach_session(session: str) -> int: + command = ["tmux", "switch-client" if os.environ.get("TMUX") else "attach-session", "-t", session] + return subprocess.run(command, check=False).returncode def build_parser() -> argparse.ArgumentParser: @@ -83,9 +97,9 @@ def build_parser() -> argparse.ArgumentParser: start = subparsers.add_parser("start", help="start or reuse a tamq endpoint") start.add_argument("repos", nargs="*", help="gita-registered repository slugs") - start.add_argument("--cmd", default="codex", help="agent command for new windows") + start.add_argument("--command", "--cmd", dest="agent_command", default="codex", help="agent command for new windows (default: codex)") start.add_argument("--detach", action="store_true", help="detach after startup") - start.add_argument("--no-service", action="store_true", help="skip service startup") + start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging") attach = subparsers.add_parser("attach", help="attach to the managed tmux session") attach.add_argument("--session", default="tamq", help="tmux session name") @@ -168,8 +182,7 @@ def main(argv: list[str] | None = None) -> int: except KeyboardInterrupt: return 0 return 0 if args.command == "attach": - result = subprocess.run(["tmux", "attach-session", "-t", args.session], check=False) - return result.returncode + return attach_session(args.session) if args.command == "completion": print(completion_script(args.shell), end="") return 0 @@ -189,30 +202,56 @@ def main(argv: list[str] | None = None) -> int: finally: store.close() if args.command == "start": + manager = TmuxManager() + try: + preflight_runtime_paths() + launch_plan = manager.preflight(args.repos, args.agent_command) + except (TmuxError, OSError) as exc: + print(f"tamq: {exc}", file=sys.stderr) + return 2 if not args.no_service and not ensure_service(): print("tamq service failed to start; use --no-service to open repos without messaging", file=sys.stderr) return 1 try: - endpoint = TmuxManager().ensure(args.repos, args.cmd) + endpoint = manager.ensure_plan(launch_plan) except (TmuxError, OSError) as exc: print(f"tamq: {exc}", file=sys.stderr) return 2 - registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos})) - if not registration.get("ok"): - print(f"tamq: endpoint registration failed: {registration.get('error', 'unknown error')}", file=sys.stderr) - return 1 - control = ControlModeClient(endpoint.session) - queue_store = Store(db_path()) - try: - control.start() - delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending( - control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}" - ) - finally: - control.close() - queue_store.close() - print(json.dumps({"endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "session": endpoint.session, "repos": endpoint.repos, "delivered": delivered})) - return 0 + delivered = 0 + registered = False + if not args.no_service: + try: + registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos})) + if not registration.get("ok"): + raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}") + registered = True + control = ControlModeClient(endpoint.session) + queue_store = Store(db_path()) + try: + control.start() + delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending( + control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}" + ) + finally: + control.close() + queue_store.close() + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + manager.rollback(endpoint) + print(f"tamq: {exc}", file=sys.stderr) + return 1 + summary = { + "endpoint_id": endpoint.endpoint_id, + "instance_id": endpoint.instance_key, + "session": endpoint.session, + "repos": endpoint.repos, + "delivered": delivered, + "service": not args.no_service, + "messaging": registered, + } + print(json.dumps(summary), flush=True) + if args.detach: + return 0 + return attach_session(endpoint.session) store = Store(db_path()) try: advisory = history_advisory(store) diff --git a/src/tamq/control.py b/src/tamq/control.py index 3b3f798..393c8a2 100644 --- a/src/tamq/control.py +++ b/src/tamq/control.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import subprocess +from collections.abc import Sequence from dataclasses import dataclass @@ -11,13 +13,19 @@ class ControlModeError(RuntimeError): @dataclass class ControlModeClient: session: str + tmux_command: Sequence[str] | None = None process: subprocess.Popen[str] | None = None def start(self) -> None: if self.process is not None: return + socket_name = os.environ.get("TAMQ_TMUX_SOCKET") + command = tuple( + self.tmux_command + or (["tmux", "-L", socket_name] if socket_name else ["tmux"]) + ) self.process = subprocess.Popen( - ["tmux", "-C", "attach-session", "-t", self.session], + [*command, "-C", "attach-session", "-t", self.session], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -37,7 +45,13 @@ class ControlModeClient: def close(self) -> None: if self.process is not None: - self.process.terminate() + process = self.process + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1) self.process = None diff --git a/src/tamq/service.py b/src/tamq/service.py index c81a009..8005d5f 100644 --- a/src/tamq/service.py +++ b/src/tamq/service.py @@ -62,7 +62,7 @@ class Service: """Continuously inject pending messages into registered live endpoints.""" while not stopped.is_set(): try: - await asyncio.to_thread(self._deliver_once) + self._deliver_once() except Exception: # A disappearing tmux session must not take down the queue. pass diff --git a/src/tamq/store.py b/src/tamq/store.py index 2422fc4..4899743 100644 --- a/src/tamq/store.py +++ b/src/tamq/store.py @@ -57,6 +57,11 @@ class Store: def register_endpoint(self, endpoint_id: str, pid: int, session: str, repos: list[str]) -> None: import json + self.db.execute( + "UPDATE endpoints SET disconnected_at=strftime('%s','now') " + "WHERE pid=? AND session=? AND endpoint_id<>? AND disconnected_at IS NULL", + (pid, session, endpoint_id), + ) self.db.execute( "INSERT INTO endpoints(endpoint_id,pid,session,repos,connected_at,disconnected_at) VALUES(?,?,?,?,strftime('%s','now'),NULL) " "ON CONFLICT(endpoint_id) DO UPDATE SET pid=excluded.pid, session=excluded.session, repos=excluded.repos, connected_at=excluded.connected_at, disconnected_at=NULL", @@ -73,17 +78,21 @@ class Store: self.db.commit() def endpoints(self) -> list[sqlite3.Row]: - return list(self.db.execute("SELECT * FROM endpoints ORDER BY endpoint_id")) + return list( + self.db.execute( + "SELECT * FROM endpoints WHERE disconnected_at IS NULL ORDER BY endpoint_id" + ) + ) def endpoint(self, endpoint_id: str) -> sqlite3.Row | None: row = self.db.execute("SELECT * FROM endpoints WHERE endpoint_id=? AND disconnected_at IS NULL", (endpoint_id,)).fetchone() if row is not None: return row if endpoint_id.startswith("tmux-amq-"): - try: - pid = int(endpoint_id.removeprefix("tmux-amq-").split("-", 1)[0]) - except ValueError: + visible_pid = endpoint_id.removeprefix("tmux-amq-") + if not visible_pid.isdigit(): return None + pid = int(visible_pid) return self.db.execute("SELECT * FROM endpoints WHERE pid=? AND disconnected_at IS NULL ORDER BY connected_at DESC LIMIT 1", (pid,)).fetchone() return None diff --git a/src/tamq/tmux.py b/src/tamq/tmux.py index 9ce202b..f299deb 100644 --- a/src/tamq/tmux.py +++ b/src/tamq/tmux.py @@ -2,9 +2,12 @@ from __future__ import annotations import os import shlex +import shutil from uuid import uuid4 import subprocess +from collections.abc import Sequence from dataclasses import dataclass +from pathlib import Path from .registry import repository_paths, validate_targets @@ -19,6 +22,8 @@ class Endpoint: pid: int repos: list[str] instance_id: str = "" + created_session: bool = False + created_windows: tuple[str, ...] = () @property def endpoint_id(self) -> str: @@ -29,48 +34,154 @@ class Endpoint: return self.instance_id or self.endpoint_id +@dataclass(frozen=True) +class LaunchPlan: + repos: tuple[str, ...] + paths: dict[str, str] + command: tuple[str, ...] + + class TmuxManager: - def __init__(self, session: str = "tamq"): + def __init__( + self, + session: str = "tamq", + *, + tmux_command: Sequence[str] | None = None, + tamq_command: Sequence[str] | None = None, + ): self.session = session + socket_name = os.environ.get("TAMQ_TMUX_SOCKET") + self.tmux_command = tuple(tmux_command or (["tmux", "-L", socket_name] if socket_name else ["tmux"])) + self.tamq_command = tuple(tamq_command or ["tamq"]) def _run(self, *args: str, check: bool = True) -> str: - result = subprocess.run(["tmux", *args], text=True, capture_output=True, check=False) + result = subprocess.run([*self.tmux_command, *args], text=True, capture_output=True, check=False) if check and result.returncode: raise TmuxError(result.stderr.strip() or "tmux command failed") return result.stdout.strip() - def ensure(self, repos: list[str], command: str = "codex") -> Endpoint: - if not repos: - raise TmuxError("at least one repository is required") - try: - validate_targets(repos) - except Exception as exc: - raise TmuxError(str(exc)) from exc - result = subprocess.run(["tmux", "has-session", "-t", self.session], capture_output=True) + def _existing_session_identity(self) -> tuple[int, str] | None: + result = subprocess.run( + [*self.tmux_command, "has-session", "-t", self.session], + capture_output=True, + ) if result.returncode != 0: - self._run("new-session", "-d", "-s", self.session, "-n", "__tamq_boot", "-c", os.getcwd(), "sleep", "infinity") + return None pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}") try: pid = int(pid_text) except ValueError as exc: raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc - endpoint = Endpoint(self.session, pid, list(dict.fromkeys(repos)), f"tmux-amq-{pid}-{uuid4().hex[:12]}") + instance_id = self._run( + "show-options", "-v", "-t", self.session, "@tamq_instance_id", check=False, + ) + managed = self._run( + "show-options", "-v", "-t", self.session, "@tamq_managed", check=False, + ) + if managed != "1" and not instance_id.startswith(f"tmux-amq-{pid}-"): + raise TmuxError( + f"tmux session already exists and is not managed by tamq: {self.session}" + ) + return pid, instance_id + + def preflight(self, repos: list[str], command: str = "codex") -> LaunchPlan: + if not repos: + raise TmuxError("at least one repository is required") + if not self.tmux_command or shutil.which(self.tmux_command[0]) is None: + raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH") + if shutil.which("gita") is None: + raise TmuxError("gita is required; install it and ensure 'gita' is on PATH") + try: + command_parts = tuple(shlex.split(command)) + except ValueError as exc: + raise TmuxError(f"invalid agent command: {exc}") from exc + if not command_parts: + raise TmuxError("agent command must not be empty") + if shutil.which(command_parts[0]) is None: + raise TmuxError(f"agent command not found on PATH: {command_parts[0]}") + unique_repos = tuple(dict.fromkeys(repos)) + try: + validate_targets(list(unique_repos)) + except Exception as exc: + raise TmuxError(str(exc)) from exc paths = repository_paths() - windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines() - for index, repo in enumerate(endpoint.repos): - if repo not in windows: + selected_paths: dict[str, str] = {} + for repo in unique_repos: + try: + repo_path = paths[repo] + except KeyError as exc: + raise TmuxError(f"gita path missing for registered repository: {repo}") from exc + if not Path(repo_path).is_dir(): + raise TmuxError(f"gita path is not a directory for registered repository {repo}: {repo_path}") + selected_paths[repo] = repo_path + self._existing_session_identity() + return LaunchPlan(unique_repos, selected_paths, command_parts) + + def ensure(self, repos: list[str], command: str = "codex") -> Endpoint: + return self.ensure_plan(self.preflight(repos, command)) + + def ensure_plan(self, plan: LaunchPlan) -> Endpoint: + created_session = False + created_windows: list[str] = [] + try: + identity = self._existing_session_identity() + if identity is None: + first_repo = plan.repos[0] + self._run( + "new-session", "-d", "-s", self.session, "-n", "__tamq_boot", + "-c", plan.paths[first_repo], + ) + created_session = True + pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}") + try: + pid = int(pid_text) + except ValueError as exc: + raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc + instance_id = "" + else: + pid, instance_id = identity + endpoint_id = f"tmux-amq-{pid}" + if not instance_id.startswith(f"{endpoint_id}-"): + instance_id = f"{endpoint_id}-{uuid4().hex[:12]}" + self._run("set-option", "-t", self.session, "@tamq_instance_id", instance_id) + self._run("set-option", "-t", self.session, "@tamq_managed", "1") + windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines() + for index, repo in enumerate(plan.repos): + if repo in windows: + continue if index == 0 and "__tamq_boot" in windows: + if not created_session: + self._run("respawn-pane", "-k", "-t", f"{self.session}:__tamq_boot", "-c", plan.paths[repo]) self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo) windows[windows.index("__tamq_boot")] = repo else: - try: - repo_path = paths[repo] - except KeyError as exc: - raise TmuxError(f"gita path missing for registered repository: {repo}") from exc - self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", repo_path, "sleep", "infinity") - tap = ["tamq", "tap", "--repo", repo, "--endpoint", endpoint.endpoint_id, "--", command] + self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", plan.paths[repo]) + windows.append(repo) + created_windows.append(repo) + tap = [ + *self.tamq_command, + "tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command, + ] self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(tap), "C-m") - return endpoint + self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}") + return Endpoint( + self.session, + pid, + list(plan.repos), + instance_id, + created_session, + tuple(created_windows), + ) + except Exception: + self.rollback(Endpoint(self.session, 0, list(plan.repos), created_session=created_session, created_windows=tuple(created_windows))) + raise + + def rollback(self, endpoint: Endpoint) -> None: + if endpoint.created_session: + self._run("kill-session", "-t", endpoint.session, check=False) + return + for window in reversed(endpoint.created_windows): + self._run("kill-window", "-t", f"{endpoint.session}:{window}", check=False) def shell_join(parts: list[str]) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0173619..1a7ea13 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ -from tamq.cli import main +import json + +from tamq.cli import build_parser, main import pytest @@ -13,6 +15,122 @@ def test_help_and_version(capsys): def test_attach_delegates_to_tmux(monkeypatch): calls = [] + monkeypatch.delenv("TMUX", raising=False) monkeypatch.setattr("tamq.cli.subprocess.run", lambda args, check=False: calls.append((args, check)) or type("R", (), {"returncode": 0})()) assert main(["attach", "--session", "demo"]) == 0 assert calls == [(["tmux", "attach-session", "-t", "demo"], False)] + + +def test_attach_switches_client_when_already_in_tmux(monkeypatch): + calls = [] + monkeypatch.setenv("TMUX", "/tmp/tmux,1,0") + monkeypatch.setattr("tamq.cli.subprocess.run", lambda args, check=False: calls.append(args) or type("R", (), {"returncode": 0})()) + assert main(["attach"]) == 0 + assert calls == [["tmux", "switch-client", "-t", "tamq"]] + + +def test_start_parser_supports_command_and_cmd_alias(): + parser = build_parser() + canonical = parser.parse_args(["start", "--command", "codex --quiet", "a", "b"]) + compatibility = parser.parse_args(["start", "--cmd", "claude", "a"]) + assert canonical.agent_command == "codex --quiet" + assert canonical.repos == ["a", "b"] + assert compatibility.agent_command == "claude" + + +def test_detached_no_service_start_skips_socket_registration(monkeypatch, capsys): + endpoint = type( + "Endpoint", + (), + { + "endpoint_id": "tmux-amq-42", + "instance_key": "tmux-amq-42-boot", + "pid": 42, + "session": "tamq", + "repos": ["a", "b"], + }, + )() + + class Manager: + def preflight(self, repos, command): + assert repos == ["a", "b"] + assert command == "codex" + return "plan" + + def ensure_plan(self, plan): + assert plan == "plan" + return endpoint + + monkeypatch.setattr("tamq.cli.TmuxManager", Manager) + monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None) + monkeypatch.setattr("tamq.cli.request", lambda payload: pytest.fail("socket request must not run")) + monkeypatch.setattr("tamq.cli.attach_session", lambda session: pytest.fail("detached start must not attach")) + assert main(["start", "--detach", "--no-service", "a", "b"]) == 0 + summary = json.loads(capsys.readouterr().out) + assert summary["service"] is False + assert summary["messaging"] is False + assert summary["repos"] == ["a", "b"] + + +def test_interactive_no_service_start_attaches(monkeypatch, capsys): + endpoint = type( + "Endpoint", + (), + { + "endpoint_id": "tmux-amq-42", + "instance_key": "tmux-amq-42-boot", + "session": "tamq", + "repos": ["a"], + }, + )() + + class Manager: + def preflight(self, repos, command): + return "plan" + + def ensure_plan(self, plan): + return endpoint + + attached = [] + monkeypatch.setattr("tamq.cli.TmuxManager", Manager) + monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None) + monkeypatch.setattr("tamq.cli.attach_session", lambda session: attached.append(session) or 0) + assert main(["start", "--no-service", "a"]) == 0 + assert attached == ["tamq"] + assert json.loads(capsys.readouterr().out)["repos"] == ["a"] + + +def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys): + endpoint = type( + "Endpoint", + (), + { + "endpoint_id": "tmux-amq-42", + "instance_key": "tmux-amq-42-boot", + "pid": 42, + "session": "tamq", + "repos": ["a"], + }, + )() + rolled_back = [] + + class Manager: + def preflight(self, repos, command): + return "plan" + + def ensure_plan(self, plan): + return endpoint + + def rollback(self, value): + rolled_back.append(value) + + async def failed_registration(payload): + return {"ok": False, "error": "denied"} + + monkeypatch.setattr("tamq.cli.TmuxManager", Manager) + monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None) + monkeypatch.setattr("tamq.cli.ensure_service", lambda: True) + monkeypatch.setattr("tamq.cli.request", failed_registration) + assert main(["start", "--detach", "a"]) == 1 + assert rolled_back == [endpoint] + assert "endpoint registration failed: denied" in capsys.readouterr().err diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index a479ee9..2bc6185 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -13,3 +13,12 @@ def test_endpoint_resolves_visible_pid_id(tmp_path): store = Store(tmp_path / "queue.sqlite3") store.register_endpoint("tmux-amq-1-boot", 1, "tamq", ["a"]) assert store.endpoint("tmux-amq-1")["endpoint_id"] == "tmux-amq-1-boot" + + +def test_endpoint_registration_retires_previous_instance_for_session(tmp_path): + store = Store(tmp_path / "queue.sqlite3") + store.register_endpoint("tmux-amq-1-old", 1, "tamq", ["a"]) + store.register_endpoint("tmux-amq-1-new", 1, "tamq", ["a", "b"]) + + assert store.endpoint("tmux-amq-1-old") is None + assert [row["endpoint_id"] for row in store.endpoints()] == ["tmux-amq-1-new"] diff --git a/tests/test_install_target.py b/tests/test_install_target.py new file mode 100644 index 0000000..5eed6a4 --- /dev/null +++ b/tests/test_install_target.py @@ -0,0 +1,180 @@ +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + + +def test_make_install_uses_isolated_uv_tool_install(): + makefile = Path("Makefile").read_text(encoding="utf-8") + assert "install:" in makefile + assert "uv tool install --force --reinstall --refresh ." in makefile + assert "tamq --version" in makefile + assert "uninstall:" in makefile + assert "uv tool uninstall tmux-amq" in makefile + + +@pytest.mark.skipif( + shutil.which("uv") is None or shutil.which("tmux") is None, + reason="installed-package smoke requires uv and tmux", +) +def test_isolated_installed_tool_session_smoke(tmp_path): + project = Path(__file__).resolve().parents[1] + tool_dir = tmp_path / "tools" + bin_dir = tmp_path / "bin" + runtime_dir = tmp_path / "runtime" + state_dir = tmp_path / "state" + repo_a = tmp_path / "railiance-platform" + repo_b = tmp_path / "activity-core" + for path in (bin_dir, runtime_dir, repo_a, repo_b): + path.mkdir(parents=True) + + gita = bin_dir / "gita" + gita.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"paths = {{'railiance-platform': {str(repo_a)!r}, 'activity-core': {str(repo_b)!r}}}\n" + "if sys.argv[1:] == ['ls']:\n" + " print('railiance-platform activity-core')\n" + "elif sys.argv[1:] == ['freeze']:\n" + " for slug, path in paths.items(): print(f'local,{slug},{path}')\n" + "else:\n" + " raise SystemExit(2)\n", + encoding="utf-8", + ) + agent = bin_dir / "alpha-agent" + agent.write_text( + "#!/bin/sh\nprintf 'installed-alpha-ready\\n'\nexec sleep 30\n", + encoding="utf-8", + ) + gita.chmod(0o755) + agent.chmod(0o755) + + socket_name = f"tamq-installed-{os.getpid()}" + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_dir}:{env['PATH']}", + "UV_TOOL_DIR": str(tool_dir), + "UV_TOOL_BIN_DIR": str(bin_dir), + "UV_CACHE_DIR": str(tmp_path / "uv-cache"), + "XDG_RUNTIME_DIR": str(runtime_dir), + "TAMQ_STATE_DIR": str(state_dir), + "TAMQ_TMUX_SOCKET": socket_name, + } + ) + tamq = bin_dir / "tamq" + tmux = ["tmux", "-L", socket_name] + + def run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=tmp_path, + env=env, + text=True, + capture_output=True, + check=True, + timeout=30, + ) + + try: + subprocess.run( + ["uv", "tool", "install", "--force", "--reinstall", "--refresh", str(project)], + cwd=project, + env=env, + text=True, + capture_output=True, + check=True, + timeout=60, + ) + assert run(str(tamq), "--version").stdout.strip() == "0.1.0" + started = json.loads( + run( + str(tamq), + "start", + "--detach", + "--command", + "alpha-agent", + "railiance-platform", + "activity-core", + ).stdout + ) + assert started["repos"] == ["railiance-platform", "activity-core"] + rows = run( + *tmux, + "list-windows", + "-t", + "tamq", + "-F", + "#{window_name}|#{pane_current_path}|#{pane_pid}", + ).stdout.splitlines() + parsed = {name: (path, pid) for name, path, pid in (row.split("|", 2) for row in rows)} + assert parsed["railiance-platform"][0] == str(repo_a) + assert parsed["activity-core"][0] == str(repo_b) + + repeated = json.loads( + run( + str(tamq), + "start", + "--detach", + "--command", + "alpha-agent", + "railiance-platform", + "activity-core", + ).stdout + ) + assert repeated["instance_id"] == started["instance_id"] + repeated_rows = run( + *tmux, + "list-windows", + "-t", + "tamq", + "-F", + "#{window_name}|#{pane_current_path}|#{pane_pid}", + ).stdout.splitlines() + assert repeated_rows == rows + pre_delivery_status = json.loads(run(str(tamq), "status").stdout) + assert pre_delivery_status["service"] is True + assert [item["endpoint_id"] for item in pre_delivery_status["endpoints"]] == [ + started["instance_id"] + ] + + message_id = run( + str(tamq), "send", "@activity-core:", "installed-message" + ).stdout.strip() + deadline = time.monotonic() + 5 + history = [] + while time.monotonic() < deadline: + history = [ + json.loads(line) + for line in run(str(tamq), "history", "--repo", "activity-core").stdout.splitlines() + ] + if history and history[-1]["state"] == "injected": + break + time.sleep(0.05) + assert history[-1]["message_id"] == message_id + assert history[-1]["state"] == "injected" + status = json.loads(run(str(tamq), "status").stdout) + assert status["service"] is True + assert [item["endpoint_id"] for item in status["endpoints"]] == [started["instance_id"]] + assert run(str(tamq), "stop").returncode == 0 + finally: + subprocess.run( + [str(tamq), "stop"], + cwd=tmp_path, + env=env, + text=True, + capture_output=True, + check=False, + timeout=5, + ) + subprocess.run( + [*tmux, "kill-server"], + env=env, + text=True, + capture_output=True, + check=False, + ) diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py new file mode 100644 index 0000000..b0f0166 --- /dev/null +++ b/tests/test_tmux_integration.py @@ -0,0 +1,113 @@ +import os +import shutil +import sys +import time +from pathlib import Path +from uuid import uuid4 + +import pytest + +from tamq.broker import BrokerIdentity, InputBroker +from tamq.control import ControlModeClient +from tamq.store import Store +from tamq.tmux import LaunchPlan, TmuxManager + + +@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed") +def test_real_tmux_starts_two_repo_windows_and_reuses_them(tmp_path, monkeypatch): + repo_a = tmp_path / "railiance-platform" + repo_b = tmp_path / "activity-core" + repo_a.mkdir() + repo_b.mkdir() + project_src = str(Path(__file__).resolve().parents[1] / "src") + existing_pythonpath = os.environ.get("PYTHONPATH") + monkeypatch.setenv("PYTHONPATH", project_src if not existing_pythonpath else f"{project_src}:{existing_pythonpath}") + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + + socket_name = f"tamq-pytest-{os.getpid()}-{uuid4().hex[:8]}" + session = f"tamq-test-{uuid4().hex[:8]}" + manager = TmuxManager( + session, + tmux_command=("tmux", "-L", socket_name), + tamq_command=(sys.executable, "-m", "tamq.cli"), + ) + plan = LaunchPlan( + ("railiance-platform", "activity-core"), + {"railiance-platform": str(repo_a), "activity-core": str(repo_b)}, + ("sh", "-c", "printf 'alpha-ready\\n'; exec sleep 30"), + ) + + try: + endpoint = manager.ensure_plan(plan) + assert endpoint.created_session is True + assert endpoint.created_windows == ("railiance-platform", "activity-core") + + rows = manager._run( + "list-windows", + "-t", + session, + "-F", + "#{window_name}|#{pane_current_path}|#{pane_pid}", + ).splitlines() + parsed = {name: (path, pid) for name, path, pid in (row.split("|", 2) for row in rows)} + assert set(parsed) == {"railiance-platform", "activity-core"} + assert parsed["railiance-platform"][0] == str(repo_a) + assert parsed["activity-core"][0] == str(repo_b) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + captures = [ + manager._run("capture-pane", "-p", "-t", f"{session}:{repo}") + for repo in plan.repos + ] + if all("alpha-ready" in capture for capture in captures): + break + time.sleep(0.05) + assert all("alpha-ready" in capture for capture in captures) + + reused = manager.ensure_plan(plan) + assert reused.created_session is False + assert reused.created_windows == () + reused_rows = manager._run( + "list-windows", + "-t", + session, + "-F", + "#{window_name}|#{pane_current_path}|#{pane_pid}", + ).splitlines() + reused_parsed = {name: (path, pid) for name, path, pid in (row.split("|", 2) for row in reused_rows)} + assert reused_parsed == parsed + assert reused.instance_id == endpoint.instance_id + + store = Store(tmp_path / "state" / "tamq.sqlite3") + control = ControlModeClient( + session, + tmux_command=("tmux", "-L", socket_name), + ) + try: + message_id = store.add("local", "activity-core", "integration-message") + control.start() + delivered = InputBroker( + store, + BrokerIdentity(endpoint.instance_id, "railiance-platform"), + ).deliver_pending( + control, + window_for_repo=lambda repo: f"{session}:{repo}", + ) + assert delivered == 1 + assert store.list(state="injected")[0]["message_id"] == message_id + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + capture = manager._run( + "capture-pane", "-p", "-t", f"{session}:activity-core" + ) + if "#local: integration-message" in capture: + break + time.sleep(0.05) + assert "#local: integration-message" in capture + finally: + control.close() + store.close() + finally: + manager._run("kill-server", check=False) diff --git a/tests/test_tmux_manager.py b/tests/test_tmux_manager.py index cccfb55..bf7b2ee 100644 --- a/tests/test_tmux_manager.py +++ b/tests/test_tmux_manager.py @@ -1,11 +1,17 @@ from tamq import tmux +import pytest -def test_tmux_manager_builds_tap_windows(monkeypatch): +def test_tmux_manager_builds_tap_windows(tmp_path, monkeypatch): calls = [] manager = tmux.TmuxManager("tamq-test") + repo_a = tmp_path / "a" + repo_b = tmp_path / "b" + repo_a.mkdir() + repo_b.mkdir() monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) - monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": "/tmp/a", "b": "/tmp/b"}) + monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo_a), "b": str(repo_b)}) + monkeypatch.setattr(tmux.shutil, "which", lambda command: f"/bin/{command}") def run(*args, check=True): calls.append(args) if args[:2] == ("list-windows", "-t"): @@ -15,6 +21,95 @@ def test_tmux_manager_builds_tap_windows(monkeypatch): return "" monkeypatch.setattr(manager, "_run", run) monkeypatch.setattr(tmux.subprocess, "run", lambda *args, **kwargs: type("R", (), {"returncode": 1})()) - endpoint = manager.ensure(["a", "b"], "codex") + endpoint = manager.ensure(["a", "b"], "codex --quiet") assert endpoint.endpoint_id == "tmux-amq-42" - assert any("tamq tap" in " ".join(call) for call in calls if call and call[0] == "send-keys") + assert endpoint.instance_id.startswith("tmux-amq-42-") + assert endpoint.repos == ["a", "b"] + new_session = next(call for call in calls if call and call[0] == "new-session") + assert new_session[-2:] == ("-c", str(repo_a)) + assert "sleep" not in new_session + new_window = next(call for call in calls if call and call[0] == "new-window") + assert new_window[-2:] == ("-c", str(repo_b)) + tap_calls = [call for call in calls if call and call[0] == "send-keys"] + assert len(tap_calls) == 2 + assert all("tamq tap" in " ".join(call) for call in tap_calls) + assert all("codex --quiet" in " ".join(call) for call in tap_calls) + assert ("select-window", "-t", "tamq-test:a") in calls + assert any(call and call[0] == "set-option" for call in calls) + + +def test_tmux_manager_reuses_session_instance_id(tmp_path, monkeypatch): + repo = tmp_path / "a" + repo.mkdir() + manager = tmux.TmuxManager("tamq-test") + options = {"@tamq_managed": "1"} + monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) + monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) + monkeypatch.setattr(tmux.shutil, "which", lambda command: f"/bin/{command}") + + def run(*args, check=True): + if args[:2] == ("display-message", "-p"): + return "42" + if args and args[0] == "show-options": + return options.get(args[-1], "") + if args and args[0] == "set-option": + options[args[-2]] = args[-1] + if args[:2] == ("list-windows", "-t"): + return "a" + return "" + + monkeypatch.setattr(manager, "_run", run) + monkeypatch.setattr( + tmux.subprocess, + "run", + lambda *args, **kwargs: type("R", (), {"returncode": 0})(), + ) + + first = manager.ensure(["a"]) + second = manager.ensure(["a"]) + assert first.instance_id == second.instance_id + + +def test_preflight_rejects_foreign_session(tmp_path, monkeypatch): + repo = tmp_path / "a" + repo.mkdir() + manager = tmux.TmuxManager("tamq-test") + monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) + monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) + monkeypatch.setattr(tmux.shutil, "which", lambda command: f"/bin/{command}") + monkeypatch.setattr( + tmux.subprocess, + "run", + lambda *args, **kwargs: type("R", (), {"returncode": 0})(), + ) + + def run(*args, check=True): + if args[:2] == ("display-message", "-p"): + return "42" + return "" + + monkeypatch.setattr(manager, "_run", run) + with pytest.raises(tmux.TmuxError, match="not managed by tamq"): + manager.preflight(["a"]) + + +def test_preflight_rejects_missing_agent(tmp_path, monkeypatch): + repo = tmp_path / "a" + repo.mkdir() + manager = tmux.TmuxManager("tamq-test") + monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) + monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) + monkeypatch.setattr(tmux.shutil, "which", lambda command: None if command == "missing-agent" else f"/bin/{command}") + with pytest.raises(tmux.TmuxError, match="agent command not found"): + manager.preflight(["a"], "missing-agent") + + +def test_preflight_deduplicates_repositories(tmp_path, monkeypatch): + repo = tmp_path / "a" + repo.mkdir() + manager = tmux.TmuxManager("tamq-test") + monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) + monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) + monkeypatch.setattr(tmux.shutil, "which", lambda command: f"/bin/{command}") + plan = manager.preflight(["a", "a"], "codex") + assert plan.repos == ("a",) diff --git a/workplans/TAMQ-WP-0004-operator-installable-local-alpha.md b/workplans/TAMQ-WP-0004-operator-installable-local-alpha.md index e57dbf1..8ea1835 100644 --- a/workplans/TAMQ-WP-0004-operator-installable-local-alpha.md +++ b/workplans/TAMQ-WP-0004-operator-installable-local-alpha.md @@ -4,7 +4,7 @@ type: workplan title: "Operator-installable local alpha session" domain: communication repo: tmux-amq -status: active +status: finished owner: codex topic_slug: coulomb-social planning_priority: P0 @@ -100,7 +100,7 @@ tamq attach ```task id: TAMQ-WP-0004-T01 -status: todo +status: done priority: high state_hub_task_id: "017c4c30-3e1c-5939-b700-e73912e4a01e" ``` @@ -111,11 +111,16 @@ installs. Verify `command -v tamq`, `tamq --version`, and invocation from outsid the checkout. Document the corresponding uninstall command and executable-path expectation. +Completed with a cache-refreshing `uv tool install` target and matching +uninstall target. The installed command resolves from the user tool bin, reports +version `0.1.0`, and is exercised from an isolated directory by the installed +package smoke test. + ## Make session startup match the CLI contract ```task id: TAMQ-WP-0004-T02 -status: todo +status: done priority: high state_hub_task_id: "8e926f2b-b7b2-53f5-81e1-9bdd3c4b7d3e" ``` @@ -130,11 +135,16 @@ new window and preserve idempotent reuse of existing windows. Define and test `--no-service` as an explicitly degraded tmux-only mode or remove it from the alpha surface. +Completed with canonical `--command`, compatible `--cmd`, default Codex, +attach-by-default, working detached and explicit no-service modes, exact first +and subsequent window paths, tmux-scoped endpoint identity, and idempotent +window/process reuse. + ## Add installation and startup preflight ```task id: TAMQ-WP-0004-T03 -status: todo +status: done priority: high state_hub_task_id: "479dd94e-e21f-512d-80e7-2453cf163b67" ``` @@ -145,11 +155,15 @@ and session conflicts. Return concise remediation for every failure and avoid leaving boot windows, sockets, pidfiles, or processes behind after a failed start. +Completed with command, gita target/path, runtime-path, agent, and managed-session +checks before mutation. Registration failures roll back newly created tmux +resources, and foreign same-named sessions are rejected rather than adopted. + ## Prove the installed local session end to end ```task id: TAMQ-WP-0004-T04 -status: wait +status: done priority: high state_hub_task_id: "76990b5d-b0d7-562f-8316-1b9cc452cadf" ``` @@ -162,11 +176,19 @@ same-named windows open in their registered paths with Codex running automatically, along with attach/detach semantics, idempotent reuse, message injection/history, shutdown, and cleanup. This task follows T01-T03. +Completed with both a real isolated-tmux integration test and an isolated uv +tool installation smoke test. The latter starts the two required repository +windows through the installed CLI, verifies exact paths and stable pane PIDs on +repeat, routes a message into `activity-core`, observes `injected` history, +checks status, stops the service, and bounds tmux cleanup. The local operator +acceptance used the same repository operands with Codex and left both live panes +ready for attachment. + ## Publish the alpha quickstart and evidence ```task id: TAMQ-WP-0004-T05 -status: wait +status: done priority: medium state_hub_task_id: "e98eda1e-6200-5bfb-8873-c0a210484fc1" ``` @@ -176,3 +198,8 @@ upgrade, start, attach, status, stop, and uninstall commands. Record the exact local acceptance commands and results. Clearly retain the reliability caveats owned by `TAMQ-WP-0003` and do not claim coordination-engine integration from `TAMQ-WP-0002`. This task follows T04. + +Completed in `README.md`, `AGENTS.md`, the stack command rules, and `SCOPE.md`. +The quickstart documents install/upgrade, exact Codex start, detached start, +attach, status, stop, tmux cleanup, and uninstall while retaining the bounded +retry, acknowledgement, and coordination-adapter caveats.