feat: deliver installable local alpha sessions
Some checks failed
tamq-ci / test (push) Failing after 6s
Some checks failed
tamq-ci / test (push) Failing after 6s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
d56bd3c79f
commit
5774fbd548
18 changed files with 890 additions and 94 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
Makefile
11
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
|
||||
|
|
|
|||
77
README.md
77
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.
|
||||
|
|
|
|||
32
SCOPE.md
32
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
155
src/tamq/tmux.py
155
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
180
tests/test_install_target.py
Normal file
180
tests/test_install_target.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
113
tests/test_tmux_integration.py
Normal file
113
tests/test_tmux_integration.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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",)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue