feat: add reliable trigger and worker message blocks
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:
tegwick 2026-08-25 23:22:39 +02:00
parent 72f22a13f7
commit 17b6bd7ae7
25 changed files with 588 additions and 67 deletions

View file

@ -7,6 +7,9 @@ The local alpha provides tmux endpoint lifecycle, local SQLite history and
leases, readable `To:` routing, JSONL export/replay, and a Unix-socket protocol
for a later coordination-engine adapter.
Agents and operators should start with the concise
[TAMQ messaging introduction](TamqMessagingIntroduction.md).
## Install and start a local session
Prerequisites must be available on `PATH`: Python 3.11+, [uv](https://docs.astral.sh/uv/),
@ -74,7 +77,7 @@ tamq --mode trigger --command codex flex-auth audit-core
All messaging-enabled windows use the same terminal-neutral PTY observer,
whether they contain a shell, a tool, or an explicitly selected coding agent.
An operator or a worker can emit the same readable line:
An operator submits one addressed line:
```text
To:audit-core: Please review the authentication change.
@ -86,6 +89,16 @@ terminal programs, tamq also recognizes `To:` immediately after a conservative
worker-output gutter such as the `•` used to frame assistant output; operator
prompt gutters are not worker output.
A worker may extend its addressed first line with non-empty follow-up output.
The first empty line terminates and sends the block:
```text
To:audit-core: Please review the authentication change.
Context: AUTH-WP-0004-T02.
Expected reply: accepted or blocked.
```
Operator input is forwarded unchanged and is delivered with `/o`; a line
originating in worker output has no suffix:
@ -94,12 +107,14 @@ From:flex-auth/o: Please review the authentication change.
From:flex-auth: Worker-generated message.
```
`From:` is never routable. The observer suppresses recent exact operator lines
Every delivered physical line carries the same `From:` prefix and is never
routable. The observer suppresses recent exact operator lines
when a TUI echoes or redraws them, so the echo cannot become a second worker
message. Ambiguous exact echoes fail closed. Pushy and trigger still cannot
infer the foreground program or protect input already being edited, so both
remain explicit experiments. Startup requires the readable-duplex, trigger,
and line-limit capabilities and replaces an incompatible old broker.
worker-block, and line-limit capabilities and replaces incompatible old broker
or tap processes.
## Exchange messages manually
@ -110,9 +125,10 @@ Each managed window has its own repository identity. From operator input in the
To:audit-core: please review the auth boundary
```
The same line at the start of worker output queues a worker-originated message.
The keyword may use any letter case, and a recognized full-screen output gutter
may precede it.
The same line at the start of worker output begins a worker-originated message
block. Non-empty follow-up rows are included until an empty row, a new `To:`
line, or worker exit. The keyword may use any letter case, and a recognized
full-screen output gutter may precede it.
The `@`, `#`, reply shorthand, and interactive recipient composer from earlier
alphas have been removed.
@ -181,8 +197,10 @@ cannot be combined with `--all` or `--json`.
Outside a managed window, use `tamq inbox --repo audit-core` and optionally
`--json`. Output-displayed messages remain durable and pending until
acknowledged. Neither `output` nor `inbox` mode injects terminal keystrokes.
Pushy places input without Enter; trigger adds exactly one Enter. Both input
modes record accepted delivery as `injected`.
Pushy places input without Enter. Trigger waits for terminal paste detection to
settle and then adds exactly one Enter. A short endpoint-startup grace protects
the first input delivery while the foreground program initializes. Both input
modes record accepted placement as `injected`, not recipient acknowledgement.
Every session window has independent, session-lifetime running counters for
accepted outbound messages, operator input lines, and normalized worker output
@ -281,8 +299,9 @@ observation and message identity.
Normal endpoints use terminal-output delivery: each message is written once to
the target pane's PTY output and stays pending in the durable inbox until
acknowledged. `--mode inbox` selects inbox-only manual mode. Neither becomes
pane input. Experimental `--mode pushy` places one sanitized `From:` line
without Enter; `--mode trigger` performs the same placement and submits once.
pane input. Experimental `--mode pushy` places a sanitized `From:` block
without Enter; `--mode trigger` performs the same placement, lets paste
detection settle, and submits once.
The PTY observer records explicit operator/worker provenance and suppresses
echoed operator lines before they can route again.
The older pane-delivery experiment remains available only with the explicit

View file

@ -26,7 +26,8 @@ tamq does not choose or infer them.
- Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
broker for integration experiments.
- Exact `gita` validation and direct, case-insensitive `To:repo:` keyword
routing from operator input or normalized worker output.
routing from operator input or normalized worker-output blocks terminated by
an empty line.
- Operator-only allowlisted `Cmd:` runtime changes and atomic, session-lifetime
per-window message/input/output line budgets.
- Unix-socket operations for local clients and a future coordination-engine
@ -62,9 +63,9 @@ transport.
| Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. |
| Neutral tmux session lifecycle | Implemented for local alpha | Repository-first startup opens untouched shells at exact gita paths, exports per-window identity, and runs no initial command unless `--command` is explicit. Stable reuse, service restart, and cleanup are covered by the installed-package test. |
| Emergency local cleanup | Implemented for local alpha | `tamq cleanup` dry-runs by default; confirmed cleanup verifies ownership before stopping the broker or marked session, clears only transient DB state, removes configured runtime files/generated shims/owned stale tmux sockets, and preserves history. |
| Safe output messaging | Implemented for local alpha | Normal endpoints write one sanitized `From:` line above a stable shell input row without injecting stdin; `/o` marks operator origin. Messages remain pending until acknowledgement. Inbox-only mode is explicit. |
| Experimental pushy and trigger delivery | Explicit opt-in | Pushy places a non-routable `From:` line without Enter; trigger adds exactly one Enter. Both are capability-gated and mark accepted delivery `injected`, but cannot identify pane occupants or protect input already being edited. |
| Full-duplex observation | Implemented for managed messaging | Every messaging-enabled new window runs its neutral shell or explicit command behind the PTY observer. It preserves geometry, resize, mouse input, and raw forwarding; normalizes worker output, recognizes conservative full-screen worker-output gutters, deduplicates redraws, and fails closed on exact recent operator echoes. |
| Safe output messaging | Implemented for local alpha | Normal endpoints write one sanitized, non-routable `From:` block above a stable shell input row without injecting stdin; `/o` marks operator origin. Messages remain pending until acknowledgement. Inbox-only mode is explicit. |
| Experimental pushy and trigger delivery | Explicit opt-in | Pushy places a non-routable `From:` block without Enter; trigger waits past paste detection and adds exactly one Enter. A startup grace protects first delivery. Both are capability-gated and mark accepted delivery `injected`, but cannot identify pane occupants or protect input already being edited. |
| Full-duplex observation | Implemented for managed messaging | Every messaging-enabled new window runs its neutral shell or explicit command behind the PTY observer. It preserves geometry, resize, mouse input, and raw forwarding; collects worker `To:` blocks through an empty row, recognizes conservative full-screen worker-output gutters and row gaps, deduplicates redraws, and fails closed on exact recent operator echoes. |
| Runtime commands and generation budgets | Implemented for local alpha | Operator-only `Cmd:` changes mode or per-window limits and resets the current ledger. Defaults are 8 message, 1024 input, and 32768 output lines. Admission and counter increments are atomic and survive service/tap restarts in one session generation. |
| Bounded retry behavior | Not enforced | Failed output or 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 | Terminal output remains pending until explicit acknowledgement, while legacy pane injection becomes `injected`; the configured `delivery_ack_mode` does not yet govern both paths. |
@ -103,7 +104,7 @@ Not yet suitable:
and stronger process-supervision evidence.
- Cross-host messaging or use as a general-purpose broker.
The suite currently has 136 passing tests. It includes atomic counter races,
The suite currently has 166 passing tests. It includes atomic counter races,
pseudo-terminal normalization, real tmux pushy/trigger behavior, operator-echo
suppression, and an isolated installed-package workflow with deterministic gita
fixtures. Forgejo CI installs tmux and uv and retains CLI help/version smoke

View file

@ -0,0 +1,129 @@
# TAMQ messaging for repository agents
TAMQ is a local, durable coordination channel between repository work sessions.
It lets an operator or worker in one managed terminal address another session by
its exact `gita` repository slug. TAMQ transports the message; it does not grant
authority, share memory, or prove that the recipient completed the request.
## The protocol in one minute
An operator can submit one addressed line:
```text
To:flex-auth: Please review AUTH-WP-0004-T02 and reply with accepted or blocked.
```
A worker can emit a short block. The first line carries the address, subsequent
non-empty output lines extend the same message, and an empty line sends it:
```text
To:flex-auth: Please review the authorization boundary.
Context: AUTH-WP-0004-T02 in docs/auth-boundary.md.
Expected reply: accepted, or one blocking question. Do not modify files yet.
```
The empty line matters. Until it appears, TAMQ keeps collecting worker output.
A new `To:` line or worker exit also closes the current block. Keep detailed
designs and evidence in durable files; use the message to point at them.
Keywords are case-insensitive (`to:`, `TO:`, and mixed case work), while the
repository slug must match `gita` exactly. Canonical `To:` spelling is easiest
to recognize in prompts and documentation.
The receiving session gets a non-routable envelope. Each physical line repeats
the sender so terminal input observation cannot mistake a continuation for a
new outbound message:
```text
From:audit-core: Please review the authorization boundary.
From:audit-core: Context: AUTH-WP-0004-T02 in docs/auth-boundary.md.
From:audit-core: Expected reply: accepted, or one blocking question. Do not modify files yet.
```
`From:audit-core/o:` means the message originated in operator input. Plain
`From:audit-core:` means it originated in worker output.
## Starting a session
TAMQ never chooses a pane occupant. Start ordinary shells:
```bash
tamq flex-auth audit-core
```
Or explicitly start the command you want in every new repository window:
```bash
tamq --command codex --mode trigger flex-auth audit-core
```
Repeated starts reuse existing windows and do not rerun the command. Use
`make cleanup` before intentionally recreating the managed session.
## Delivery modes
The endpoint mode controls what delivery means:
| Mode | Recipient behavior |
| --- | --- |
| `output` | Safe default. Display the `From:` block as terminal output; do not give it to the foreground process. |
| `inbox` | Keep the message durable without displaying it. Consume it with `tamq inbox`. |
| `pushy` | Experimental. Place the block in the target input buffer without submitting it. |
| `trigger` | Experimental. Place the block, allow terminal paste detection to settle, then submit it once. |
New pushy/trigger endpoints have a short startup grace so their foreground
program can initialize before the first delivery. These modes still cannot
prove that an arbitrary program accepted or understood the input. A durable
state of `injected` records successful terminal placement, not agent
acknowledgement.
An operator can change the live mode:
```text
Cmd: mode=trigger
```
`Cmd:` is operator-only. Command-shaped worker output is inert.
## Agent behavior
When asked to contact another repository worker:
1. Emit one `To:<repo>:` block and finish it with an empty line.
2. Include the durable task, file, commit, or correlation ID when relevant.
3. State the expected response and any authority boundary.
4. Do not echo an operator's addressed input unless explicitly asked; TAMQ may
already have observed it and an echo could create a duplicate.
5. Treat `From:` as received context, not as permission to act beyond the
current task and repository rules.
Good messages are small handoffs, completion notices, acknowledgements, or
bounded questions. Never send secrets, credentials, tokens, or private keys.
## Inspecting and troubleshooting
Useful commands are:
```bash
tamq status
tamq history --repo audit-core
tamq inbox --repo audit-core
tamq inspect <message-id>
tamq ack <message-id>
tamq send --from audit-core 'To:flex-auth: Direct CLI message.'
```
If no reply arrives, distinguish these states before resending:
- no durable history row: the source line or completed worker block was not
observed, the slug was invalid, or a line budget blocked it;
- `pending`: no eligible live endpoint has completed the selected delivery;
- `displayed`: output mode showed it, but did not submit it to the worker;
- `injected`: pushy/trigger placement succeeded, but the worker has not
necessarily processed it;
- explicit reply or acknowledgement: the recipient confirmed receipt.
Use a correlation token when duplicate requests would be harmful. TAMQ is an
alpha transport: durable queue state and recipient comprehension are separate
facts.

View file

@ -24,6 +24,7 @@
| workplan | TAMQ-WP-0012 | finished | — | workplans/TAMQ-WP-0012-pushy-feedback-circuit-breaker.md |
| workplan | TAMQ-WP-0013 | finished | — | workplans/TAMQ-WP-0013-emergency-cleanup.md |
| workplan | TAMQ-WP-0014 | finished | — | workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md |
| workplan | TAMQ-WP-0015 | finished | — | workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md |
| task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md |
| task | TAMQ-WP-ADHOC-2026-08-25-T01 | done | — | workplans/ADHOC-2026-08-25.md |
| task | TAMQ-WP-ADHOC-2026-08-25-T02 | done | — | workplans/ADHOC-2026-08-25.md |
@ -84,3 +85,7 @@
| task | TAMQ-WP-0014-T05 | done | — | workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md |
| task | TAMQ-WP-0014-T06 | done | — | workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md |
| task | TAMQ-WP-0014-T07 | done | — | workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md |
| task | TAMQ-WP-0015-T01 | done | — | workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md |
| task | TAMQ-WP-0015-T02 | done | — | workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md |
| task | TAMQ-WP-0015-T03 | done | — | workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md |
| task | TAMQ-WP-0015-T04 | done | — | workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md |

View file

@ -10,6 +10,7 @@ from .routing import (
parse_address_line,
parse_command_line,
parse_worker_address_line,
worker_content_line,
)
from .store import LimitBlock, Store
from .terminal import format_delivery
@ -35,6 +36,8 @@ class InputBroker:
self.store = store
self.identity = identity
self.notify = notify
self._worker_target: str | None = None
self._worker_lines: list[str] = []
self.store.configure_window(identity.endpoint_id, identity.source_repo, limits)
def _notice(self, text: str) -> None:
@ -48,17 +51,7 @@ class InputBroker:
"in this terminal. Use 'Cmd: reset-limits' to unblock."
)
def _route(
self,
line: str,
provenance: str,
*,
worker_output: bool = False,
) -> RoutedMessage | None:
parser = parse_worker_address_line if worker_output else parse_address_line
routed = parser(line)
if routed is None:
return None
def _admit(self, routed: RoutedMessage, provenance: str) -> RoutedMessage | None:
try:
validate_targets([routed.target_repo])
except RegistryError:
@ -76,6 +69,30 @@ class InputBroker:
return None
return routed
def _route(self, line: str, provenance: str) -> RoutedMessage | None:
routed = parse_address_line(line)
return None if routed is None else self._admit(routed, provenance)
def _start_worker_message(self, routed: RoutedMessage) -> None:
try:
validate_targets([routed.target_repo])
except RegistryError:
return
self._worker_target = routed.target_repo
self._worker_lines = [routed.body]
def flush_worker_message(self) -> RoutedMessage | None:
"""Admit the worker block accumulated through its empty terminator."""
if self._worker_target is None:
return None
routed = RoutedMessage(
target_repo=self._worker_target,
body="\n".join(self._worker_lines),
)
self._worker_target = None
self._worker_lines = []
return self._admit(routed, "worker_output")
def inspect_operator_line(self, line: str) -> RoutedMessage | None:
"""Observe one submitted operator line; it is still forwarded unchanged."""
if is_delivery_line(line):
@ -90,13 +107,26 @@ class InputBroker:
return self._route(line, "operator_input")
def inspect_worker_line(self, line: str) -> RoutedMessage | None:
"""Observe worker output; Cmd is intentionally inert on this path."""
if is_delivery_line(line):
"""Collect a To block through its first empty worker-output line."""
if self._worker_target is None and is_delivery_line(line):
return None
if self._worker_target is None and not worker_content_line(line).strip():
return None
self.store.count_line(
self.identity.endpoint_id, self.identity.source_repo, "output"
)
return self._route(line, "worker_output", worker_output=True)
routed = parse_worker_address_line(line)
if routed is not None:
previous = self.flush_worker_message()
self._start_worker_message(routed)
return previous
if self._worker_target is None:
return None
content = worker_content_line(line).strip()
if not content:
return self.flush_worker_message()
self._worker_lines.append(content)
return None
def inspect_line(self, line: str) -> RoutedMessage | None:
"""Compatibility entry point for operator-input observers."""

View file

@ -35,6 +35,7 @@ from .cleanup import cleanup_runtime
from .diagnostics import configure
from .policy import load_profile
from .registry import RegistryError, validate_targets
from .routing import parse_address_line
from .terminal import format_delivery
@ -197,6 +198,7 @@ def build_parser() -> argparse.ArgumentParser:
duplex messaging from a managed terminal:
To:TARGET: MESSAGE... operator input or worker output (case-insensitive)
FOLLOW-UP... + blank line worker output extends and sends one message block
Cmd: mode=trigger operator-only runtime command
Cmd: reset-limits reset this window's running counters
tamq inbox [--filter COMMAND]
@ -509,10 +511,10 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "send":
endpoint_id = None
text = " ".join([args.address, *args.body]).strip()
if not text.startswith("To:") or text.count(":") < 2:
routed = parse_address_line(text)
if routed is None:
print("tamq send expects To:repo: message", file=sys.stderr); return 2
target, body = text[3:].split(":", 1); body = body.strip()
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
target, body = routed.target_repo, routed.body
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
endpoint_id = args.endpoint_id
try:

View file

@ -24,6 +24,7 @@ class ControlModeClient:
session: str
tmux_command: Sequence[str] | None = None
process: subprocess.Popen[str] | None = None
submit_delay: float = 0.15
def _command(self) -> tuple[str, ...]:
socket_name = os.environ.get("TAMQ_TMUX_SOCKET")
@ -94,12 +95,15 @@ class ControlModeClient:
self.command(f"send-keys -t {window} -l -- {shell_quote(text)}")
def submit(self, window: str, text: str) -> None:
"""Inject literal text and Enter as one checked tmux command list."""
"""Place text, let paste detection settle, then submit exactly once."""
delay = max(0.0, self.submit_delay)
result = subprocess.run(
[
*self._command(),
"send-keys", "-t", window, "-l", "--", text,
";",
"run-shell", f"sleep {delay:.3f}",
";",
"send-keys", "-t", window, "Enter",
],
text=True,

View file

@ -60,6 +60,10 @@ class TerminalOutputObserver:
self.text: list[str] = []
self.state = "normal"
self.string_kind = ""
self.csi: list[str] = []
self.cursor_row: int | None = None
self.after_cr = False
self.empty_cr = False
self.operator_lines: deque[tuple[str, float]] = deque(maxlen=32)
self.last_redraw: str | None = None
@ -72,10 +76,12 @@ class TerminalOutputObserver:
self.operator_lines.popleft()
return any(candidate == line for candidate, _ in self.operator_lines)
def _emit(self, *, redraw: bool = False) -> None:
def _emit(self, *, redraw: bool = False, allow_empty: bool = False) -> None:
line = "".join(self.text)
self.text.clear()
if not line:
if allow_empty:
self.on_line("")
return
if self._is_recent_operator_echo(line):
return
@ -84,21 +90,62 @@ class TerminalOutputObserver:
self.last_redraw = line if redraw else None
self.on_line(line)
def _move_cursor(self, final: str) -> None:
parameters = "".join(self.csi)
previous = self.cursor_row
new_row: int | None = None
try:
first = int((parameters.split(";", 1)[0] or "1"))
except ValueError:
first = 1
if final in "Hf":
new_row = first
elif final == "d":
new_row = first
elif final in "BE" and previous is not None:
new_row = previous + first
elif final in "AF" and previous is not None:
new_row = max(1, previous - first)
if (
previous is not None
and new_row is not None
and new_row > previous + 1
):
self.on_line("")
if new_row is not None:
self.cursor_row = new_row
def feed(self, data: bytes) -> None:
for character in self.decoder.decode(data):
if self.state == "normal":
if character == "\x1b":
self.after_cr = False
self.state = "esc"
elif character in "\r\n":
self._emit(redraw=character == "\r")
elif character == "\r":
self.empty_cr = not self.text
self._emit(redraw=True)
self.after_cr = True
elif character == "\n":
if self.after_cr:
if self.empty_cr:
self._emit(allow_empty=True)
else:
self._emit(allow_empty=True)
self.after_cr = False
self.empty_cr = False
if self.cursor_row is not None:
self.cursor_row += 1
elif character in ("\b", "\x7f"):
self.after_cr = False
if self.text:
self.text.pop()
elif character == "\t" or character >= " ":
self.after_cr = False
self.text.append(character)
elif self.state == "esc":
if character == "[":
self.state = "csi"
self.csi.clear()
elif character in "]P_^":
self.state = "string"
self.string_kind = character
@ -108,7 +155,10 @@ class TerminalOutputObserver:
if "@" <= character <= "~":
if character in self._CURSOR_BOUNDARIES:
self._emit(redraw=True)
self._move_cursor(character)
self.state = "normal"
else:
self.csi.append(character)
elif self.state == "string":
if character == "\x07" and self.string_kind == "]":
self.state = "normal"
@ -248,6 +298,9 @@ class PtyTap:
if self.ready_file is not None:
self.ready_file.unlink(missing_ok=True)
self.output_observer.flush()
flush_worker = getattr(self.broker, "flush_worker_message", None)
if flush_worker is not None:
flush_worker()
for signum, handler in saved_handlers.items():
signal.signal(signum, handler)
if saved_terminal is not None:

View file

@ -30,9 +30,14 @@ def parse_address_line(line: str) -> RoutedMessage | None:
def parse_worker_address_line(line: str) -> RoutedMessage | None:
"""Parse an address after a conservative full-screen output gutter."""
return parse_address_line(worker_content_line(line))
def worker_content_line(line: str) -> str:
"""Remove terminal presentation framing from one worker-output row."""
candidate = line.rstrip("\r\n")
candidate = WORKER_OUTPUT_GUTTER.sub("", candidate, count=1)
return parse_address_line(candidate)
return candidate.lstrip(" \t")
def parse_command_line(line: str) -> str | None:

View file

@ -6,6 +6,7 @@ import os
import signal
import socket
import struct
import time
from pathlib import Path
from .config import socket_path, pid_path, setting
@ -16,8 +17,8 @@ from .control import ControlModeClient
from .terminal import format_delivery, terminal_frame, write_terminal_output
PROTOCOL_VERSION = "0.1"
PUSHY_FRAMING_CAPABILITY = "readable_duplex_framing_v1"
TRIGGER_CAPABILITY = "trigger_input_v1"
PUSHY_FRAMING_CAPABILITY = "readable_duplex_framing_v2"
TRIGGER_CAPABILITY = "trigger_input_v2"
LINE_LIMITS_CAPABILITY = "session_window_line_limits_v1"
SERVICE_CAPABILITIES = [
"register",
@ -33,7 +34,13 @@ SERVICE_CAPABILITIES = [
class Service:
def __init__(self, path: Path | None = None, store: Store | None = None, poll_interval: float | None = None):
def __init__(
self,
path: Path | None = None,
store: Store | None = None,
poll_interval: float | None = None,
input_grace: float | None = None,
):
self.path = path or socket_path()
self.store = store or Store(db_path())
self.pidfile = pid_path()
@ -43,6 +50,15 @@ class Service:
self.poll_interval = poll_interval if poll_interval is not None else max(0.05, float(configured_interval))
except ValueError:
self.poll_interval = poll_interval if poll_interval is not None else 0.5
configured_grace = setting("input_delivery_grace", "1.0")
try:
self.input_grace = (
max(0.0, input_grace)
if input_grace is not None
else max(0.0, float(configured_grace))
)
except ValueError:
self.input_grace = max(0.0, input_grace or 1.0)
async def run(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
@ -96,6 +112,11 @@ class Service:
delivery_mode = endpoint["delivery_mode"]
if delivery_mode == "manual":
continue
if (
delivery_mode in {"pushy", "trigger"}
and time.time() - float(endpoint["connected_at"]) < self.input_grace
):
continue
pending = [
row for row in self.store.list(state="pending")
if row["endpoint_id"] in (None, endpoint["endpoint_id"])

View file

@ -117,9 +117,9 @@ class Store:
(pid, session, endpoint_id),
)
self.db.execute(
"INSERT INTO endpoints(endpoint_id,pid,session,repos,delivery_mode,connected_at,disconnected_at) VALUES(?,?,?,?,?,strftime('%s','now'),NULL) "
"INSERT INTO endpoints(endpoint_id,pid,session,repos,delivery_mode,connected_at,disconnected_at) VALUES(?,?,?,?,?,?,NULL) "
"ON CONFLICT(endpoint_id) DO UPDATE SET pid=excluded.pid, session=excluded.session, repos=excluded.repos, delivery_mode=excluded.delivery_mode, connected_at=excluded.connected_at, disconnected_at=NULL",
(endpoint_id, pid, session, json.dumps(repos), delivery_mode),
(endpoint_id, pid, session, json.dumps(repos), delivery_mode, time.time()),
)
for repo in repos:
self.configure_window(endpoint_id, repo, limits, commit=False)

View file

@ -21,9 +21,10 @@ def terminal_safe(text: str) -> str:
def format_delivery(sender: str, body: str, provenance: str | None = None) -> str:
"""Render one readable, non-routable delivery line."""
"""Render a readable block whose every physical line is non-routable."""
suffix = "/o" if provenance == "operator_input" else ""
return f"From:{terminal_safe(sender)}{suffix}: {terminal_safe(body)}"
prefix = f"From:{terminal_safe(sender)}{suffix}: "
return "\n".join(prefix + terminal_safe(line) for line in body.split("\n"))
def terminal_frame(

View file

@ -19,6 +19,9 @@ class TmuxError(RuntimeError):
pass
DUPLEX_PROTOCOL_VERSION = "2"
@dataclass
class Endpoint:
session: str
@ -229,9 +232,9 @@ class TmuxManager:
"show-options", "-v", "-t", self.session,
"@tamq_duplex_protocol", check=False,
)
if duplex_protocol != "1":
if duplex_protocol != DUPLEX_PROTOCOL_VERSION:
raise TmuxError(
"existing managed session predates readable duplex messaging; "
"existing managed session predates worker message blocks; "
"run 'tamq cleanup --yes' before recreating it"
)
if identity is None:
@ -256,7 +259,10 @@ class TmuxManager:
self._run("set-option", "-t", self.session, "@tamq_instance_id", instance_id)
self._run("set-option", "-t", self.session, "@tamq_managed", "1")
if tap:
self._run("set-option", "-t", self.session, "@tamq_duplex_protocol", "1")
self._run(
"set-option", "-t", self.session,
"@tamq_duplex_protocol", DUPLEX_PROTOCOL_VERSION,
)
windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines()
addressable_repos = [repo for repo in windows if repo != "__tamq_boot"]
addressable_repos.extend(repo for repo in plan.repos if repo not in addressable_repos)

View file

@ -17,7 +17,8 @@ def test_broker_accepts_worker_output_with_distinct_provenance(tmp_path, monkeyp
monkeypatch.setattr("tamq.broker.validate_targets", lambda targets: None)
store = Store(tmp_path / "queue.sqlite3")
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
assert broker.inspect_worker_line("To:railiance-platform: hello") is not None
assert broker.inspect_worker_line("To:railiance-platform: hello") is None
assert broker.inspect_worker_line("") is not None
assert store.list()[0]["provenance"] == "worker_output"
@ -25,12 +26,42 @@ def test_broker_accepts_case_insensitive_framed_worker_output(tmp_path, monkeypa
monkeypatch.setattr("tamq.broker.validate_targets", lambda targets: None)
store = Store(tmp_path / "queue.sqlite3")
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
assert broker.inspect_worker_line("• tO:railiance-platform: hello") is not None
assert broker.inspect_worker_line("• tO:railiance-platform: hello") is None
assert broker.inspect_worker_line("") is not None
row = store.list()[0]
assert row["body"] == "hello"
assert row["provenance"] == "worker_output"
def test_broker_collects_worker_followup_lines_until_empty(tmp_path, monkeypatch):
monkeypatch.setattr("tamq.broker.validate_targets", lambda targets: None)
store = Store(tmp_path / "queue.sqlite3")
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
assert broker.inspect_worker_line("• To:railiance-platform: Review this") is None
assert broker.inspect_worker_line(" Context: AUTH-WP-4.") is None
assert broker.inspect_worker_line(" Reply with accepted or blocked.") is None
assert broker.inspect_worker_line("") is not None
row = store.list()[0]
assert row["body"] == (
"Review this\nContext: AUTH-WP-4.\nReply with accepted or blocked."
)
def test_new_worker_address_flushes_previous_block(tmp_path, monkeypatch):
monkeypatch.setattr("tamq.broker.validate_targets", lambda targets: None)
store = Store(tmp_path / "queue.sqlite3")
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
broker.inspect_worker_line("To:first: one")
assert broker.inspect_worker_line("To:second: two") is not None
assert broker.flush_worker_message() is not None
assert [(row["target_repo"], row["body"]) for row in store.list()] == [
("first", "one"),
("second", "two"),
]
def test_case_insensitive_delivery_envelopes_are_not_rescanned(tmp_path):
store = Store(tmp_path / "queue.sqlite3")
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))

View file

@ -23,7 +23,9 @@ def test_pane_tty_is_resolved_through_tmux(monkeypatch):
return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7|12|8|24|0\n", "stderr": ""})()
monkeypatch.setattr(control.subprocess, "run", run)
client = ControlModeClient("tamq", tmux_command=("tmux", "-L", "test"))
client = ControlModeClient(
"tamq", tmux_command=("tmux", "-L", "test"), submit_delay=0.15
)
assert client.pane_tty("tamq:audit-core") == "/dev/pts/7"
assert calls == [[
"tmux", "-L", "test", "display-message", "-p", "-t",
@ -49,6 +51,8 @@ def test_submit_sends_literal_input_then_one_enter(monkeypatch):
"send-keys", "-t", "tamq:audit-core", "-l", "--",
"#flex-auth: What's up? [m-1]",
";",
"run-shell", "sleep 0.150",
";",
"send-keys", "-t", "tamq:audit-core", "Enter",
]
]

View file

@ -25,6 +25,7 @@ def test_window_counters_survive_reopen_and_isolate_repositories(tmp_path, monke
notify=blocked.append,
)
assert left.inspect_worker_line("To:target: blocked") is None
assert left.inspect_worker_line("") is None
assert "lines of messages" in blocked[-1]
@ -43,7 +44,8 @@ def test_atomic_message_admission_does_not_cross_limit(tmp_path, monkeypatch):
BrokerIdentity("session-1", "source"),
limits=(1, 100, 100),
)
return broker.inspect_worker_line(f"To:target: {body}") is not None
broker.inspect_worker_line(f"To:target: {body}")
return broker.inspect_worker_line("") is not None
finally:
store.close()

View file

@ -15,7 +15,7 @@ def test_manual_send_inbox_and_ack_use_window_repository_identity(tmp_path, monk
monkeypatch.setattr("tamq.cli.ping", service_is_down)
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
assert main(["send", "To:audit-core:", "review", "this"]) == 0
assert main(["send", "tO:audit-core:", "review", "this"]) == 0
message_id = capsys.readouterr().out.strip()
assert main(["inbox", "--repo", "audit-core", "--json"]) == 0
@ -51,7 +51,7 @@ def test_human_inbox_uses_readable_origin_framing(tmp_path, monkeypatch, capsys)
assert main(["inbox", "--repo", "audit-core"]) == 0
assert capsys.readouterr().out == (
"From:flex-auth/o: first\\x0asecond\\x1b[31m\n"
"From:flex-auth/o: first\nFrom:flex-auth/o: second\\x1b[31m\n"
)

View file

@ -104,6 +104,24 @@ def test_output_observer_preserves_full_screen_program_gutters():
assert lines == [" To:target: operator echo", "• tO:target: worker reply"]
def test_output_observer_emits_one_empty_line_for_crlf_terminator():
lines = []
observer = TerminalOutputObserver(lines.append)
observer.feed(b"To:target: first\r\nfollowup\r\n\r\nordinary\r\n")
assert lines == ["To:target: first", "followup", "", "ordinary"]
def test_output_observer_infers_empty_full_screen_row_from_cursor_gap():
lines = []
observer = TerminalOutputObserver(lines.append)
observer.feed(
b"\x1b[4;1H\xe2\x80\xa2 To:target: first"
b"\x1b[5;1H followup"
b"\x1b[7;1Hprompt"
)
assert lines == ["• To:target: first", " followup", ""]
def test_copy_winsize_preserves_rows_columns_and_pixels():
source_master, source_slave = pty.openpty()
target_master, target_slave = pty.openpty()

View file

@ -113,7 +113,9 @@ def test_real_pty_attributes_worker_output_and_suppresses_operator_echo(
"print('READY', flush=True)\n"
"for line in sys.stdin:\n"
" print(line.rstrip('\\r\\n'), flush=True)\n"
" print('\\x1b[5;1H• tO:target: worker reply\\x1b[6;1H', flush=True)\n",
" print('\\x1b[5;1H• tO:target: worker reply'\n"
" '\\x1b[6;1H Context: integration fixture.'\n"
" '\\x1b[8;1H', flush=True)\n",
encoding="utf-8",
)
bin_dir = tmp_path / "bin"
@ -171,7 +173,7 @@ def test_real_pty_attributes_worker_output_and_suppresses_operator_echo(
time.sleep(0.05)
assert [(row["body"], row["provenance"]) for row in rows] == [
("operator request", "operator_input"),
("worker reply", "worker_output"),
("worker reply\nContext: integration fixture.", "worker_output"),
]
assert all(row["endpoint_id"] == endpoint.instance_id for row in rows)
finally:

View file

@ -69,7 +69,7 @@ def test_service_pushy_mode_places_once_without_enter(tmp_path, monkeypatch):
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "pushy")
message_id = store.add("repo-a", "repo-b", "first\nsecond")
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
service = Service(store=store)
service = Service(store=store, input_grace=0)
service._deliver_once()
control = FakeControl.instances[-1]
@ -78,7 +78,7 @@ def test_service_pushy_mode_places_once_without_enter(tmp_path, monkeypatch):
assert control.placed == [
(
"tamq:repo-b",
"From:repo-a: first\\x0asecond",
"From:repo-a: first\nFrom:repo-a: second",
)
]
assert control.submitted == []
@ -90,7 +90,7 @@ def test_service_trigger_mode_submits_exactly_once(tmp_path, monkeypatch):
store.register_endpoint("ep", 9, "tamq", ["repo-b"], "trigger")
store.add("repo-a", "repo-b", "go", provenance="operator_input")
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
service = Service(store=store)
service = Service(store=store, input_grace=0)
service._deliver_once()
control = FakeControl.instances[-1]
service._deliver_once()
@ -108,11 +108,24 @@ def test_failed_pushy_submission_remains_pending(tmp_path, monkeypatch):
raise RuntimeError("tmux rejected input")
monkeypatch.setattr("tamq.service.ControlModeClient", FailingControl)
Service(store=store)._deliver_once()
Service(store=store, input_grace=0)._deliver_once()
assert store.list()[0]["state"] == "pending"
def test_input_delivery_waits_for_new_endpoint_grace(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("ep", 9, "tamq", ["repo-b"], "trigger")
store.add("repo-a", "repo-b", "first")
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
service = Service(store=store, input_grace=10)
service._deliver_once()
assert FakeControl.instances[-1].submitted == []
assert store.list()[0]["state"] == "pending"
def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")

View file

@ -14,7 +14,7 @@ from tamq.terminal import (
def test_delivery_format_escapes_controls_and_marks_operator_origin():
assert format_delivery("repo-a", "first\nsecond\x1b[31m", "operator_input") == (
"From:repo-a/o: first\\x0asecond\\x1b[31m"
"From:repo-a/o: first\nFrom:repo-a/o: second\\x1b[31m"
)
assert format_delivery("repo-a", "worker", "worker_output") == (
"From:repo-a: worker"

View file

@ -239,28 +239,93 @@ def test_real_tmux_pushy_mode_places_one_readable_input_without_enter(tmp_path,
"pushy",
)
store.add("flex-auth", "audit-core", "What's next?\nsecond", provenance="operator_input")
service = Service(store=store)
service = Service(store=store, input_grace=0)
service._deliver_once()
expected = "From:flex-auth/o: What's next?\\x0asecond"
expected_lines = ["From:flex-auth/o: What's next?", "From:flex-auth/o: second"]
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
capture = manager._run("capture-pane", "-p", "-J", "-t", target)
if expected in capture:
if all(expected in capture for expected in expected_lines):
break
time.sleep(0.05)
assert expected in capture
assert all(expected in capture for expected in expected_lines)
assert store.list()[0]["state"] == "injected"
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
service._deliver_once()
repeated_capture = manager._run("capture-pane", "-p", "-J", "-t", target)
assert repeated_capture.count(expected) == 1
assert all(repeated_capture.count(expected) == 1 for expected in expected_lines)
finally:
store.close()
manager._run("kill-server", check=False)
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed")
def test_real_tmux_trigger_clears_terminal_paste_window_before_enter(tmp_path):
repo = tmp_path / "audit-core"
repo.mkdir()
fixture = tmp_path / "paste_sensitive_fixture.py"
fixture.write_text(
"import os, time, tty\n"
"tty.setraw(0)\n"
"os.write(1, b'READY\\r\\n')\n"
"body = bytearray()\n"
"last_text = 0.0\n"
"while True:\n"
" data = os.read(0, 4096)\n"
" now = time.monotonic()\n"
" for byte in data:\n"
" if byte == 13:\n"
" label = b'SUBMITTED:' if now - last_text >= 0.1 else b'NEWLINE:'\n"
" os.write(1, label + bytes(body) + b'\\r\\n')\n"
" raise SystemExit(0)\n"
" body.append(byte)\n"
" last_text = now\n",
encoding="utf-8",
)
socket_name = f"tamq-trigger-paste-{uuid4().hex[:8]}"
session = f"tamq-trigger-paste-{uuid4().hex[:8]}"
tmux_command = ("tmux", "-L", socket_name)
manager = TmuxManager(
session,
tmux_command=tmux_command,
tamq_command=(sys.executable, "-m", "tamq.cli"),
command_dir=tmp_path / "commands",
)
try:
manager.ensure_plan(
LaunchPlan(
("audit-core",),
{"audit-core": str(repo)},
(sys.executable, str(fixture)),
)
)
target = f"{session}:audit-core"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
capture = manager._run("capture-pane", "-p", "-t", target)
if "READY" in capture:
break
time.sleep(0.05)
assert "READY" in capture
ControlModeClient(
session, tmux_command=tmux_command, submit_delay=0.15
).submit(target, "From:flex-auth: first delivery")
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
capture = manager._run("capture-pane", "-p", "-t", target)
if "SUBMITTED:" in capture:
break
time.sleep(0.05)
assert "SUBMITTED:From:flex-auth: first delivery" in capture
assert "NEWLINE:" not in capture
finally:
manager._run("kill-server", check=False)
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed")
def test_real_tmux_to_route_triggers_once_without_feedback(tmp_path, monkeypatch):
repo_a = tmp_path / "railiance-platform"
@ -355,7 +420,7 @@ def test_real_tmux_to_route_triggers_once_without_feedback(tmp_path, monkeypatch
assert row["body"] == "Hello!"
assert row["endpoint_id"] == endpoint.instance_key
service = Service(store=store)
service = Service(store=store, input_grace=0)
service._deliver_once()
expected = "From:railiance-platform/o: Hello!"
deadline = time.monotonic() + 5

View file

@ -103,7 +103,7 @@ def test_tap_refuses_managed_session_without_duplex_protocol(tmp_path, monkeypat
plan = tmux.LaunchPlan(("a",), {"a": str(tmp_path)}, ("sh",))
monkeypatch.setattr(manager, "_existing_session_identity", lambda: (42, "tmux-amq-42-old"))
monkeypatch.setattr(manager, "_run", lambda *args, **kwargs: "")
with pytest.raises(tmux.TmuxError, match="predates readable duplex"):
with pytest.raises(tmux.TmuxError, match="predates worker message blocks"):
manager.ensure_plan(plan, tap=True)

View file

@ -16,6 +16,10 @@ state_hub_workstream_id: "3b675117-dd26-553f-adea-d0505df3cadd"
# Readable duplex messaging, runtime commands, and line limits
> Follow-up: `TAMQ-WP-0015` supersedes this workplan's single-line worker
> output contract with blank-line-terminated worker message blocks and repeats
> `From:` framing on every delivered physical line.
Replace the shell-oriented `@`/`#` protocol with a readable `To:`/`From:`
protocol that can originate in either operator input or worker output, add an
operator-only runtime command lane, separate input placement from submission,

View file

@ -0,0 +1,106 @@
---
id: TAMQ-WP-0015
type: workplan
title: "Reliable trigger delivery and worker message blocks"
domain: communication
repo: tmux-amq
status: finished
owner: codex
topic_slug: reliable-trigger-worker-blocks
created: "2026-08-25"
updated: "2026-08-25"
---
# Reliable trigger delivery and worker message blocks
Correct first-delivery and trigger behavior for interactive terminal programs,
allow agents to emit practical multi-line message blocks, and give repository
workers a concise introduction that matches the implemented protocol.
## Diagnose first delivery and trigger submission
```task
id: TAMQ-WP-0015-T01
status: done
priority: high
```
Compare durable live history with the terminal delivery path. Distinguish queue
admission, tmux placement, terminal paste detection, and agent acknowledgement.
### Evidence
- Live history showed the reported trigger message reached durable state
`injected` within roughly 40 ms but did not start an agent turn.
- Trigger previously sent literal text and Enter in one immediate tmux command
list, matching the receiving TUI's paste/newline behavior.
- Endpoint registration occurred as soon as the PTY proxy was ready, without a
foreground-program initialization grace.
## Make trigger distinct and protect first delivery
```task
id: TAMQ-WP-0015-T02
status: done
priority: high
```
Delay the one trigger Enter beyond terminal paste detection and defer input-mode
delivery briefly after endpoint registration. Retain pushy's no-Enter contract.
### Evidence
- Trigger now places text, waits 150 ms in the checked tmux command queue, and
sends exactly one Enter; pushy still performs literal placement only.
- Pushy and trigger wait a configurable one-second endpoint startup grace.
- A real tmux paste-sensitive fixture classifies immediate Enter as a newline
and the new trigger sequence as one submission.
## Collect worker output through an empty line
```task
id: TAMQ-WP-0015-T03
status: done
priority: high
```
Treat a worker `To:repo:` row as the beginning of a block. Append subsequent
non-empty worker rows and admit one durable message at an empty row, a new
address, or process exit.
### Evidence
- PTY normalization emits empty CRLF rows and infers visual empty rows from
forward cursor gaps in full-screen interfaces.
- Continuation presentation indentation is removed and durable bodies retain
newline boundaries.
- Delivery repeats the non-routable `From:` envelope on every physical line,
preventing a continuation from feeding back into routing.
- A real tmux/PTY test routes a cursor-positioned, bullet-framed worker block
with a continuation and correct `worker_output` provenance.
## Align introductory documentation and verification
```task
id: TAMQ-WP-0015-T04
status: done
priority: medium
```
Review the introduction produced during the audit-core/flex-auth conversation,
make a concise canonical copy in this repository, and align README/SCOPE/CLI
behavior with the revised protocol.
### Evidence
- `TamqMessagingIntroduction.md` now leads with the protocol, blank-line rule,
delivery modes, provenance, agent behavior, and state-based troubleshooting.
- The reviewed document is mirrored to the original untracked audit-core path.
- Direct `tamq send` now shares case-insensitive `To:` parsing with managed
terminals.
- `make check` passes 166 tests, including real tmux and PTY coverage.
## Residuals
Bounded retry and positive acknowledgement semantics remain owned by
`TAMQ-WP-0003`; this work does not claim that `injected` means agent-processed.