feat: add reliable trigger and worker message blocks
Some checks failed
tamq-ci / test (push) Failing after 6s
Some checks failed
tamq-ci / test (push) Failing after 6s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
72f22a13f7
commit
17b6bd7ae7
25 changed files with 588 additions and 67 deletions
39
README.md
39
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
|
||||
|
|
|
|||
11
SCOPE.md
11
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
|
||||
|
|
|
|||
129
TamqMessagingIntroduction.md
Normal file
129
TamqMessagingIntroduction.md
Normal 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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
106
workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md
Normal file
106
workplans/TAMQ-WP-0015-reliable-trigger-and-worker-blocks.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue