From 17b6bd7ae7f90af7642c0353bd8af30cd4fc17d8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 25 Aug 2026 23:22:39 +0200 Subject: [PATCH] feat: add reliable trigger and worker message blocks Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc --- README.md | 39 ++++-- SCOPE.md | 11 +- TamqMessagingIntroduction.md | 129 ++++++++++++++++++ WORK-RECORDS.md | 5 + src/tamq/broker.py | 58 ++++++-- src/tamq/cli.py | 8 +- src/tamq/control.py | 6 +- src/tamq/ptytap.py | 59 +++++++- src/tamq/routing.py | 7 +- src/tamq/service.py | 27 +++- src/tamq/store.py | 4 +- src/tamq/terminal.py | 5 +- src/tamq/tmux.py | 12 +- tests/test_broker.py | 35 ++++- tests/test_control.py | 6 +- tests/test_line_limits.py | 4 +- tests/test_manual_messaging.py | 4 +- tests/test_ptytap.py | 18 +++ tests/test_ptytap_integration.py | 6 +- tests/test_service_delivery.py | 21 ++- tests/test_terminal_output.py | 2 +- tests/test_tmux_integration.py | 77 ++++++++++- tests/test_tmux_manager.py | 2 +- ...14-readable-duplex-messaging-and-limits.md | 4 + ...0015-reliable-trigger-and-worker-blocks.md | 106 ++++++++++++++ 25 files changed, 588 insertions(+), 67 deletions(-) create mode 100644 TamqMessagingIntroduction.md create mode 100644 workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md diff --git a/README.md b/README.md index 62a3f36..8817040 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SCOPE.md b/SCOPE.md index 3427240..af94ae6 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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 diff --git a/TamqMessagingIntroduction.md b/TamqMessagingIntroduction.md new file mode 100644 index 0000000..7f34466 --- /dev/null +++ b/TamqMessagingIntroduction.md @@ -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::` 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 +tamq ack +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. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index aa8a0c3..37d592b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/src/tamq/broker.py b/src/tamq/broker.py index a723bf0..e04b1d1 100644 --- a/src/tamq/broker.py +++ b/src/tamq/broker.py @@ -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.""" diff --git a/src/tamq/cli.py b/src/tamq/cli.py index d49113e..9617b81 100644 --- a/src/tamq/cli.py +++ b/src/tamq/cli.py @@ -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: diff --git a/src/tamq/control.py b/src/tamq/control.py index b8287d0..1e729ac 100644 --- a/src/tamq/control.py +++ b/src/tamq/control.py @@ -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, diff --git a/src/tamq/ptytap.py b/src/tamq/ptytap.py index c378d9a..6dd9e87 100644 --- a/src/tamq/ptytap.py +++ b/src/tamq/ptytap.py @@ -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: diff --git a/src/tamq/routing.py b/src/tamq/routing.py index 3ea6c00..af18b19 100644 --- a/src/tamq/routing.py +++ b/src/tamq/routing.py @@ -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: diff --git a/src/tamq/service.py b/src/tamq/service.py index 658de09..132d57e 100644 --- a/src/tamq/service.py +++ b/src/tamq/service.py @@ -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"]) diff --git a/src/tamq/store.py b/src/tamq/store.py index 1e93d03..fe7a17c 100644 --- a/src/tamq/store.py +++ b/src/tamq/store.py @@ -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) diff --git a/src/tamq/terminal.py b/src/tamq/terminal.py index 0746a5f..a162293 100644 --- a/src/tamq/terminal.py +++ b/src/tamq/terminal.py @@ -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( diff --git a/src/tamq/tmux.py b/src/tamq/tmux.py index 4995568..e239466 100644 --- a/src/tamq/tmux.py +++ b/src/tamq/tmux.py @@ -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) diff --git a/tests/test_broker.py b/tests/test_broker.py index dc58db3..768d6c3 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -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")) diff --git a/tests/test_control.py b/tests/test_control.py index 94b15d4..a3af6be 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -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", ] ] diff --git a/tests/test_line_limits.py b/tests/test_line_limits.py index 08ee51e..8237d74 100644 --- a/tests/test_line_limits.py +++ b/tests/test_line_limits.py @@ -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() diff --git a/tests/test_manual_messaging.py b/tests/test_manual_messaging.py index 06382d0..f0612be 100644 --- a/tests/test_manual_messaging.py +++ b/tests/test_manual_messaging.py @@ -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" ) diff --git a/tests/test_ptytap.py b/tests/test_ptytap.py index a1bcebb..b523ae5 100644 --- a/tests/test_ptytap.py +++ b/tests/test_ptytap.py @@ -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() diff --git a/tests/test_ptytap_integration.py b/tests/test_ptytap_integration.py index 6560845..35b7610 100644 --- a/tests/test_ptytap_integration.py +++ b/tests/test_ptytap_integration.py @@ -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: diff --git a/tests/test_service_delivery.py b/tests/test_service_delivery.py index accfaaf..74fd0fd 100644 --- a/tests/test_service_delivery.py +++ b/tests/test_service_delivery.py @@ -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") diff --git a/tests/test_terminal_output.py b/tests/test_terminal_output.py index 0074d64..5b052b8 100644 --- a/tests/test_terminal_output.py +++ b/tests/test_terminal_output.py @@ -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" diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py index 95fab71..221e15c 100644 --- a/tests/test_tmux_integration.py +++ b/tests/test_tmux_integration.py @@ -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 diff --git a/tests/test_tmux_manager.py b/tests/test_tmux_manager.py index 0971277..0cede98 100644 --- a/tests/test_tmux_manager.py +++ b/tests/test_tmux_manager.py @@ -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) diff --git a/workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md b/workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md index 4a3ec21..e68b7bc 100644 --- a/workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md +++ b/workplans/TAMQ-WP-0014-readable-duplex-messaging-and-limits.md @@ -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, diff --git a/workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md b/workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md new file mode 100644 index 0000000..aa2ea52 --- /dev/null +++ b/workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md @@ -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.