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
|
leases, readable `To:` routing, JSONL export/replay, and a Unix-socket protocol
|
||||||
for a later coordination-engine adapter.
|
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
|
## Install and start a local session
|
||||||
|
|
||||||
Prerequisites must be available on `PATH`: Python 3.11+, [uv](https://docs.astral.sh/uv/),
|
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,
|
All messaging-enabled windows use the same terminal-neutral PTY observer,
|
||||||
whether they contain a shell, a tool, or an explicitly selected coding agent.
|
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
|
```text
|
||||||
To:audit-core: Please review the authentication change.
|
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
|
worker-output gutter such as the `•` used to frame assistant output; operator
|
||||||
prompt gutters are not worker output.
|
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
|
Operator input is forwarded unchanged and is delivered with `/o`; a line
|
||||||
originating in worker output has no suffix:
|
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: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
|
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
|
message. Ambiguous exact echoes fail closed. Pushy and trigger still cannot
|
||||||
infer the foreground program or protect input already being edited, so both
|
infer the foreground program or protect input already being edited, so both
|
||||||
remain explicit experiments. Startup requires the readable-duplex, trigger,
|
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
|
## 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
|
To:audit-core: please review the auth boundary
|
||||||
```
|
```
|
||||||
|
|
||||||
The same line at the start of worker output queues a worker-originated message.
|
The same line at the start of worker output begins a worker-originated message
|
||||||
The keyword may use any letter case, and a recognized full-screen output gutter
|
block. Non-empty follow-up rows are included until an empty row, a new `To:`
|
||||||
may precede it.
|
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
|
The `@`, `#`, reply shorthand, and interactive recipient composer from earlier
|
||||||
alphas have been removed.
|
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
|
Outside a managed window, use `tamq inbox --repo audit-core` and optionally
|
||||||
`--json`. Output-displayed messages remain durable and pending until
|
`--json`. Output-displayed messages remain durable and pending until
|
||||||
acknowledged. Neither `output` nor `inbox` mode injects terminal keystrokes.
|
acknowledged. Neither `output` nor `inbox` mode injects terminal keystrokes.
|
||||||
Pushy places input without Enter; trigger adds exactly one Enter. Both input
|
Pushy places input without Enter. Trigger waits for terminal paste detection to
|
||||||
modes record accepted delivery as `injected`.
|
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
|
Every session window has independent, session-lifetime running counters for
|
||||||
accepted outbound messages, operator input lines, and normalized worker output
|
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
|
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
|
the target pane's PTY output and stays pending in the durable inbox until
|
||||||
acknowledged. `--mode inbox` selects inbox-only manual mode. Neither becomes
|
acknowledged. `--mode inbox` selects inbox-only manual mode. Neither becomes
|
||||||
pane input. Experimental `--mode pushy` places one sanitized `From:` line
|
pane input. Experimental `--mode pushy` places a sanitized `From:` block
|
||||||
without Enter; `--mode trigger` performs the same placement and submits once.
|
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
|
The PTY observer records explicit operator/worker provenance and suppresses
|
||||||
echoed operator lines before they can route again.
|
echoed operator lines before they can route again.
|
||||||
The older pane-delivery experiment remains available only with the explicit
|
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
|
- Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
|
||||||
broker for integration experiments.
|
broker for integration experiments.
|
||||||
- Exact `gita` validation and direct, case-insensitive `To:repo:` keyword
|
- 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
|
- Operator-only allowlisted `Cmd:` runtime changes and atomic, session-lifetime
|
||||||
per-window message/input/output line budgets.
|
per-window message/input/output line budgets.
|
||||||
- Unix-socket operations for local clients and a future coordination-engine
|
- 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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:` 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. |
|
| 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; normalizes worker output, recognizes conservative full-screen worker-output gutters, deduplicates redraws, and fails closed on exact recent operator echoes. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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.
|
and stronger process-supervision evidence.
|
||||||
- Cross-host messaging or use as a general-purpose broker.
|
- 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
|
pseudo-terminal normalization, real tmux pushy/trigger behavior, operator-echo
|
||||||
suppression, and an isolated installed-package workflow with deterministic gita
|
suppression, and an isolated installed-package workflow with deterministic gita
|
||||||
fixtures. Forgejo CI installs tmux and uv and retains CLI help/version smoke
|
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-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-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-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-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-T01 | done | — | workplans/ADHOC-2026-08-25.md |
|
||||||
| task | TAMQ-WP-ADHOC-2026-08-25-T02 | 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-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-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-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_address_line,
|
||||||
parse_command_line,
|
parse_command_line,
|
||||||
parse_worker_address_line,
|
parse_worker_address_line,
|
||||||
|
worker_content_line,
|
||||||
)
|
)
|
||||||
from .store import LimitBlock, Store
|
from .store import LimitBlock, Store
|
||||||
from .terminal import format_delivery
|
from .terminal import format_delivery
|
||||||
|
|
@ -35,6 +36,8 @@ class InputBroker:
|
||||||
self.store = store
|
self.store = store
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.notify = notify
|
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)
|
self.store.configure_window(identity.endpoint_id, identity.source_repo, limits)
|
||||||
|
|
||||||
def _notice(self, text: str) -> None:
|
def _notice(self, text: str) -> None:
|
||||||
|
|
@ -48,17 +51,7 @@ class InputBroker:
|
||||||
"in this terminal. Use 'Cmd: reset-limits' to unblock."
|
"in this terminal. Use 'Cmd: reset-limits' to unblock."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _route(
|
def _admit(self, routed: RoutedMessage, provenance: str) -> RoutedMessage | None:
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
validate_targets([routed.target_repo])
|
validate_targets([routed.target_repo])
|
||||||
except RegistryError:
|
except RegistryError:
|
||||||
|
|
@ -76,6 +69,30 @@ class InputBroker:
|
||||||
return None
|
return None
|
||||||
return routed
|
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:
|
def inspect_operator_line(self, line: str) -> RoutedMessage | None:
|
||||||
"""Observe one submitted operator line; it is still forwarded unchanged."""
|
"""Observe one submitted operator line; it is still forwarded unchanged."""
|
||||||
if is_delivery_line(line):
|
if is_delivery_line(line):
|
||||||
|
|
@ -90,13 +107,26 @@ class InputBroker:
|
||||||
return self._route(line, "operator_input")
|
return self._route(line, "operator_input")
|
||||||
|
|
||||||
def inspect_worker_line(self, line: str) -> RoutedMessage | None:
|
def inspect_worker_line(self, line: str) -> RoutedMessage | None:
|
||||||
"""Observe worker output; Cmd is intentionally inert on this path."""
|
"""Collect a To block through its first empty worker-output line."""
|
||||||
if is_delivery_line(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
|
return None
|
||||||
self.store.count_line(
|
self.store.count_line(
|
||||||
self.identity.endpoint_id, self.identity.source_repo, "output"
|
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:
|
def inspect_line(self, line: str) -> RoutedMessage | None:
|
||||||
"""Compatibility entry point for operator-input observers."""
|
"""Compatibility entry point for operator-input observers."""
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from .cleanup import cleanup_runtime
|
||||||
from .diagnostics import configure
|
from .diagnostics import configure
|
||||||
from .policy import load_profile
|
from .policy import load_profile
|
||||||
from .registry import RegistryError, validate_targets
|
from .registry import RegistryError, validate_targets
|
||||||
|
from .routing import parse_address_line
|
||||||
from .terminal import format_delivery
|
from .terminal import format_delivery
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -197,6 +198,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
duplex messaging from a managed terminal:
|
duplex messaging from a managed terminal:
|
||||||
To:TARGET: MESSAGE... operator input or worker output (case-insensitive)
|
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: mode=trigger operator-only runtime command
|
||||||
Cmd: reset-limits reset this window's running counters
|
Cmd: reset-limits reset this window's running counters
|
||||||
tamq inbox [--filter COMMAND]
|
tamq inbox [--filter COMMAND]
|
||||||
|
|
@ -509,10 +511,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
if args.command == "send":
|
if args.command == "send":
|
||||||
endpoint_id = None
|
endpoint_id = None
|
||||||
text = " ".join([args.address, *args.body]).strip()
|
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
|
print("tamq send expects To:repo: message", file=sys.stderr); return 2
|
||||||
target, body = text[3:].split(":", 1); body = body.strip()
|
target, body = routed.target_repo, routed.body
|
||||||
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
|
|
||||||
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
|
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
|
||||||
endpoint_id = args.endpoint_id
|
endpoint_id = args.endpoint_id
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class ControlModeClient:
|
||||||
session: str
|
session: str
|
||||||
tmux_command: Sequence[str] | None = None
|
tmux_command: Sequence[str] | None = None
|
||||||
process: subprocess.Popen[str] | None = None
|
process: subprocess.Popen[str] | None = None
|
||||||
|
submit_delay: float = 0.15
|
||||||
|
|
||||||
def _command(self) -> tuple[str, ...]:
|
def _command(self) -> tuple[str, ...]:
|
||||||
socket_name = os.environ.get("TAMQ_TMUX_SOCKET")
|
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)}")
|
self.command(f"send-keys -t {window} -l -- {shell_quote(text)}")
|
||||||
|
|
||||||
def submit(self, window: str, text: str) -> None:
|
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(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
*self._command(),
|
*self._command(),
|
||||||
"send-keys", "-t", window, "-l", "--", text,
|
"send-keys", "-t", window, "-l", "--", text,
|
||||||
";",
|
";",
|
||||||
|
"run-shell", f"sleep {delay:.3f}",
|
||||||
|
";",
|
||||||
"send-keys", "-t", window, "Enter",
|
"send-keys", "-t", window, "Enter",
|
||||||
],
|
],
|
||||||
text=True,
|
text=True,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ class TerminalOutputObserver:
|
||||||
self.text: list[str] = []
|
self.text: list[str] = []
|
||||||
self.state = "normal"
|
self.state = "normal"
|
||||||
self.string_kind = ""
|
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.operator_lines: deque[tuple[str, float]] = deque(maxlen=32)
|
||||||
self.last_redraw: str | None = None
|
self.last_redraw: str | None = None
|
||||||
|
|
||||||
|
|
@ -72,10 +76,12 @@ class TerminalOutputObserver:
|
||||||
self.operator_lines.popleft()
|
self.operator_lines.popleft()
|
||||||
return any(candidate == line for candidate, _ in self.operator_lines)
|
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)
|
line = "".join(self.text)
|
||||||
self.text.clear()
|
self.text.clear()
|
||||||
if not line:
|
if not line:
|
||||||
|
if allow_empty:
|
||||||
|
self.on_line("")
|
||||||
return
|
return
|
||||||
if self._is_recent_operator_echo(line):
|
if self._is_recent_operator_echo(line):
|
||||||
return
|
return
|
||||||
|
|
@ -84,21 +90,62 @@ class TerminalOutputObserver:
|
||||||
self.last_redraw = line if redraw else None
|
self.last_redraw = line if redraw else None
|
||||||
self.on_line(line)
|
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:
|
def feed(self, data: bytes) -> None:
|
||||||
for character in self.decoder.decode(data):
|
for character in self.decoder.decode(data):
|
||||||
if self.state == "normal":
|
if self.state == "normal":
|
||||||
if character == "\x1b":
|
if character == "\x1b":
|
||||||
|
self.after_cr = False
|
||||||
self.state = "esc"
|
self.state = "esc"
|
||||||
elif character in "\r\n":
|
elif character == "\r":
|
||||||
self._emit(redraw=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"):
|
elif character in ("\b", "\x7f"):
|
||||||
|
self.after_cr = False
|
||||||
if self.text:
|
if self.text:
|
||||||
self.text.pop()
|
self.text.pop()
|
||||||
elif character == "\t" or character >= " ":
|
elif character == "\t" or character >= " ":
|
||||||
|
self.after_cr = False
|
||||||
self.text.append(character)
|
self.text.append(character)
|
||||||
elif self.state == "esc":
|
elif self.state == "esc":
|
||||||
if character == "[":
|
if character == "[":
|
||||||
self.state = "csi"
|
self.state = "csi"
|
||||||
|
self.csi.clear()
|
||||||
elif character in "]P_^":
|
elif character in "]P_^":
|
||||||
self.state = "string"
|
self.state = "string"
|
||||||
self.string_kind = character
|
self.string_kind = character
|
||||||
|
|
@ -108,7 +155,10 @@ class TerminalOutputObserver:
|
||||||
if "@" <= character <= "~":
|
if "@" <= character <= "~":
|
||||||
if character in self._CURSOR_BOUNDARIES:
|
if character in self._CURSOR_BOUNDARIES:
|
||||||
self._emit(redraw=True)
|
self._emit(redraw=True)
|
||||||
|
self._move_cursor(character)
|
||||||
self.state = "normal"
|
self.state = "normal"
|
||||||
|
else:
|
||||||
|
self.csi.append(character)
|
||||||
elif self.state == "string":
|
elif self.state == "string":
|
||||||
if character == "\x07" and self.string_kind == "]":
|
if character == "\x07" and self.string_kind == "]":
|
||||||
self.state = "normal"
|
self.state = "normal"
|
||||||
|
|
@ -248,6 +298,9 @@ class PtyTap:
|
||||||
if self.ready_file is not None:
|
if self.ready_file is not None:
|
||||||
self.ready_file.unlink(missing_ok=True)
|
self.ready_file.unlink(missing_ok=True)
|
||||||
self.output_observer.flush()
|
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():
|
for signum, handler in saved_handlers.items():
|
||||||
signal.signal(signum, handler)
|
signal.signal(signum, handler)
|
||||||
if saved_terminal is not None:
|
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:
|
def parse_worker_address_line(line: str) -> RoutedMessage | None:
|
||||||
"""Parse an address after a conservative full-screen output gutter."""
|
"""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 = line.rstrip("\r\n")
|
||||||
candidate = WORKER_OUTPUT_GUTTER.sub("", candidate, count=1)
|
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:
|
def parse_command_line(line: str) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import os
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import socket_path, pid_path, setting
|
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
|
from .terminal import format_delivery, terminal_frame, write_terminal_output
|
||||||
|
|
||||||
PROTOCOL_VERSION = "0.1"
|
PROTOCOL_VERSION = "0.1"
|
||||||
PUSHY_FRAMING_CAPABILITY = "readable_duplex_framing_v1"
|
PUSHY_FRAMING_CAPABILITY = "readable_duplex_framing_v2"
|
||||||
TRIGGER_CAPABILITY = "trigger_input_v1"
|
TRIGGER_CAPABILITY = "trigger_input_v2"
|
||||||
LINE_LIMITS_CAPABILITY = "session_window_line_limits_v1"
|
LINE_LIMITS_CAPABILITY = "session_window_line_limits_v1"
|
||||||
SERVICE_CAPABILITIES = [
|
SERVICE_CAPABILITIES = [
|
||||||
"register",
|
"register",
|
||||||
|
|
@ -33,7 +34,13 @@ SERVICE_CAPABILITIES = [
|
||||||
|
|
||||||
|
|
||||||
class Service:
|
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.path = path or socket_path()
|
||||||
self.store = store or Store(db_path())
|
self.store = store or Store(db_path())
|
||||||
self.pidfile = pid_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))
|
self.poll_interval = poll_interval if poll_interval is not None else max(0.05, float(configured_interval))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.poll_interval = poll_interval if poll_interval is not None else 0.5
|
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:
|
async def run(self) -> None:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -96,6 +112,11 @@ class Service:
|
||||||
delivery_mode = endpoint["delivery_mode"]
|
delivery_mode = endpoint["delivery_mode"]
|
||||||
if delivery_mode == "manual":
|
if delivery_mode == "manual":
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
delivery_mode in {"pushy", "trigger"}
|
||||||
|
and time.time() - float(endpoint["connected_at"]) < self.input_grace
|
||||||
|
):
|
||||||
|
continue
|
||||||
pending = [
|
pending = [
|
||||||
row for row in self.store.list(state="pending")
|
row for row in self.store.list(state="pending")
|
||||||
if row["endpoint_id"] in (None, endpoint["endpoint_id"])
|
if row["endpoint_id"] in (None, endpoint["endpoint_id"])
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,9 @@ class Store:
|
||||||
(pid, session, endpoint_id),
|
(pid, session, endpoint_id),
|
||||||
)
|
)
|
||||||
self.db.execute(
|
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",
|
"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:
|
for repo in repos:
|
||||||
self.configure_window(endpoint_id, repo, limits, commit=False)
|
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:
|
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 ""
|
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(
|
def terminal_frame(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ class TmuxError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
DUPLEX_PROTOCOL_VERSION = "2"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
session: str
|
session: str
|
||||||
|
|
@ -229,9 +232,9 @@ class TmuxManager:
|
||||||
"show-options", "-v", "-t", self.session,
|
"show-options", "-v", "-t", self.session,
|
||||||
"@tamq_duplex_protocol", check=False,
|
"@tamq_duplex_protocol", check=False,
|
||||||
)
|
)
|
||||||
if duplex_protocol != "1":
|
if duplex_protocol != DUPLEX_PROTOCOL_VERSION:
|
||||||
raise TmuxError(
|
raise TmuxError(
|
||||||
"existing managed session predates readable duplex messaging; "
|
"existing managed session predates worker message blocks; "
|
||||||
"run 'tamq cleanup --yes' before recreating it"
|
"run 'tamq cleanup --yes' before recreating it"
|
||||||
)
|
)
|
||||||
if identity is None:
|
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_instance_id", instance_id)
|
||||||
self._run("set-option", "-t", self.session, "@tamq_managed", "1")
|
self._run("set-option", "-t", self.session, "@tamq_managed", "1")
|
||||||
if tap:
|
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()
|
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 = [repo for repo in windows if repo != "__tamq_boot"]
|
||||||
addressable_repos.extend(repo for repo in plan.repos if repo not in addressable_repos)
|
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)
|
monkeypatch.setattr("tamq.broker.validate_targets", lambda targets: None)
|
||||||
store = Store(tmp_path / "queue.sqlite3")
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
|
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"
|
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)
|
monkeypatch.setattr("tamq.broker.validate_targets", lambda targets: None)
|
||||||
store = Store(tmp_path / "queue.sqlite3")
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
|
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]
|
row = store.list()[0]
|
||||||
assert row["body"] == "hello"
|
assert row["body"] == "hello"
|
||||||
assert row["provenance"] == "worker_output"
|
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):
|
def test_case_insensitive_delivery_envelopes_are_not_rescanned(tmp_path):
|
||||||
store = Store(tmp_path / "queue.sqlite3")
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
broker = InputBroker(store, BrokerIdentity("ep", "net-kingdom"))
|
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": ""})()
|
return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7|12|8|24|0\n", "stderr": ""})()
|
||||||
|
|
||||||
monkeypatch.setattr(control.subprocess, "run", run)
|
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 client.pane_tty("tamq:audit-core") == "/dev/pts/7"
|
||||||
assert calls == [[
|
assert calls == [[
|
||||||
"tmux", "-L", "test", "display-message", "-p", "-t",
|
"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", "--",
|
"send-keys", "-t", "tamq:audit-core", "-l", "--",
|
||||||
"#flex-auth: What's up? [m-1]",
|
"#flex-auth: What's up? [m-1]",
|
||||||
";",
|
";",
|
||||||
|
"run-shell", "sleep 0.150",
|
||||||
|
";",
|
||||||
"send-keys", "-t", "tamq:audit-core", "Enter",
|
"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,
|
notify=blocked.append,
|
||||||
)
|
)
|
||||||
assert left.inspect_worker_line("To:target: blocked") is None
|
assert left.inspect_worker_line("To:target: blocked") is None
|
||||||
|
assert left.inspect_worker_line("") is None
|
||||||
assert "lines of messages" in blocked[-1]
|
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"),
|
BrokerIdentity("session-1", "source"),
|
||||||
limits=(1, 100, 100),
|
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:
|
finally:
|
||||||
store.close()
|
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.ping", service_is_down)
|
||||||
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
|
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()
|
message_id = capsys.readouterr().out.strip()
|
||||||
|
|
||||||
assert main(["inbox", "--repo", "audit-core", "--json"]) == 0
|
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 main(["inbox", "--repo", "audit-core"]) == 0
|
||||||
assert capsys.readouterr().out == (
|
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"]
|
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():
|
def test_copy_winsize_preserves_rows_columns_and_pixels():
|
||||||
source_master, source_slave = pty.openpty()
|
source_master, source_slave = pty.openpty()
|
||||||
target_master, target_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"
|
"print('READY', flush=True)\n"
|
||||||
"for line in sys.stdin:\n"
|
"for line in sys.stdin:\n"
|
||||||
" print(line.rstrip('\\r\\n'), flush=True)\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",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
bin_dir = tmp_path / "bin"
|
bin_dir = tmp_path / "bin"
|
||||||
|
|
@ -171,7 +173,7 @@ def test_real_pty_attributes_worker_output_and_suppresses_operator_echo(
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
assert [(row["body"], row["provenance"]) for row in rows] == [
|
assert [(row["body"], row["provenance"]) for row in rows] == [
|
||||||
("operator request", "operator_input"),
|
("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)
|
assert all(row["endpoint_id"] == endpoint.instance_id for row in rows)
|
||||||
finally:
|
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")
|
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "pushy")
|
||||||
message_id = store.add("repo-a", "repo-b", "first\nsecond")
|
message_id = store.add("repo-a", "repo-b", "first\nsecond")
|
||||||
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||||
service = Service(store=store)
|
service = Service(store=store, input_grace=0)
|
||||||
|
|
||||||
service._deliver_once()
|
service._deliver_once()
|
||||||
control = FakeControl.instances[-1]
|
control = FakeControl.instances[-1]
|
||||||
|
|
@ -78,7 +78,7 @@ def test_service_pushy_mode_places_once_without_enter(tmp_path, monkeypatch):
|
||||||
assert control.placed == [
|
assert control.placed == [
|
||||||
(
|
(
|
||||||
"tamq:repo-b",
|
"tamq:repo-b",
|
||||||
"From:repo-a: first\\x0asecond",
|
"From:repo-a: first\nFrom:repo-a: second",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
assert control.submitted == []
|
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.register_endpoint("ep", 9, "tamq", ["repo-b"], "trigger")
|
||||||
store.add("repo-a", "repo-b", "go", provenance="operator_input")
|
store.add("repo-a", "repo-b", "go", provenance="operator_input")
|
||||||
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||||
service = Service(store=store)
|
service = Service(store=store, input_grace=0)
|
||||||
service._deliver_once()
|
service._deliver_once()
|
||||||
control = FakeControl.instances[-1]
|
control = FakeControl.instances[-1]
|
||||||
service._deliver_once()
|
service._deliver_once()
|
||||||
|
|
@ -108,11 +108,24 @@ def test_failed_pushy_submission_remains_pending(tmp_path, monkeypatch):
|
||||||
raise RuntimeError("tmux rejected input")
|
raise RuntimeError("tmux rejected input")
|
||||||
|
|
||||||
monkeypatch.setattr("tamq.service.ControlModeClient", FailingControl)
|
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"
|
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):
|
def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatch):
|
||||||
store = Store(tmp_path / "queue.sqlite3")
|
store = Store(tmp_path / "queue.sqlite3")
|
||||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")
|
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():
|
def test_delivery_format_escapes_controls_and_marks_operator_origin():
|
||||||
assert format_delivery("repo-a", "first\nsecond\x1b[31m", "operator_input") == (
|
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") == (
|
assert format_delivery("repo-a", "worker", "worker_output") == (
|
||||||
"From:repo-a: worker"
|
"From:repo-a: worker"
|
||||||
|
|
|
||||||
|
|
@ -239,28 +239,93 @@ def test_real_tmux_pushy_mode_places_one_readable_input_without_enter(tmp_path,
|
||||||
"pushy",
|
"pushy",
|
||||||
)
|
)
|
||||||
store.add("flex-auth", "audit-core", "What's next?\nsecond", provenance="operator_input")
|
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()
|
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
|
deadline = time.monotonic() + 5
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
capture = manager._run("capture-pane", "-p", "-J", "-t", target)
|
capture = manager._run("capture-pane", "-p", "-J", "-t", target)
|
||||||
if expected in capture:
|
if all(expected in capture for expected in expected_lines):
|
||||||
break
|
break
|
||||||
time.sleep(0.05)
|
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.list()[0]["state"] == "injected"
|
||||||
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
|
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
|
||||||
|
|
||||||
service._deliver_once()
|
service._deliver_once()
|
||||||
repeated_capture = manager._run("capture-pane", "-p", "-J", "-t", target)
|
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:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
manager._run("kill-server", check=False)
|
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")
|
@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):
|
def test_real_tmux_to_route_triggers_once_without_feedback(tmp_path, monkeypatch):
|
||||||
repo_a = tmp_path / "railiance-platform"
|
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["body"] == "Hello!"
|
||||||
assert row["endpoint_id"] == endpoint.instance_key
|
assert row["endpoint_id"] == endpoint.instance_key
|
||||||
|
|
||||||
service = Service(store=store)
|
service = Service(store=store, input_grace=0)
|
||||||
service._deliver_once()
|
service._deliver_once()
|
||||||
expected = "From:railiance-platform/o: Hello!"
|
expected = "From:railiance-platform/o: Hello!"
|
||||||
deadline = time.monotonic() + 5
|
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",))
|
plan = tmux.LaunchPlan(("a",), {"a": str(tmp_path)}, ("sh",))
|
||||||
monkeypatch.setattr(manager, "_existing_session_identity", lambda: (42, "tmux-amq-42-old"))
|
monkeypatch.setattr(manager, "_existing_session_identity", lambda: (42, "tmux-amq-42-old"))
|
||||||
monkeypatch.setattr(manager, "_run", lambda *args, **kwargs: "")
|
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)
|
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
|
# 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:`
|
Replace the shell-oriented `@`/`#` protocol with a readable `To:`/`From:`
|
||||||
protocol that can originate in either operator input or worker output, add an
|
protocol that can originate in either operator input or worker output, add an
|
||||||
operator-only runtime command lane, separate input placement from submission,
|
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