Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/listener-reliability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Listener reliability
on:
push:
branches: ['fix/listener-reliability-*']
pull_request:
paths:
- 'src/msg/client_listener.py'
- 'src/msg/client_agent_cli.py'
- 'src/msg/client_subagents*.py'
- 'src/msg/client_events.py'
- 'tests/test_client_listener*.py'
- 'tests/test_client_subagents*.py'
- 'tests/test_client_agent_cli.py'
- 'src/msg/core/errors.py'
- 'scripts/benchmark_listener.py'
- '.github/workflows/listener-reliability.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: listener-${{ github.head_ref || github.ref_name }}
cancel-in-progress: false
jobs:
validation:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: msgtest
POSTGRES_PASSWORD: msgtest
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U msgtest -d postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
MSG_BENCHMARK_OUTPUT: artifacts
MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 0
- name: Record source for reproduction
run: |
mkdir -p artifacts
git rev-parse HEAD > artifacts/commit.txt
git archive --format=tar.gz -o artifacts/source.tar.gz HEAD
- uses: actions/setup-python@v7
with:
python-version: '3.15'
allow-prereleases: true
- name: Install smartcard build headers
run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev pkg-config
- name: Install locked dependencies
run: |
python -m pip install uv==0.12.20
uv sync --locked --extra dev --python "$(command -v python)"
- name: Listener, local mailbox and private remote mailbox regressions
run: >-
uv run --locked --extra dev pytest
tests/test_client_listener.py tests/test_client_subagents.py
tests/test_client_subagents_remote.py tests/test_client_agent_cli.py
tests/test_client_listener_reliability.py tests/test_client_listener_remote.py
tests/test_client_listener_cli.py
--junitxml=artifacts/listener.xml
- name: Record bounded local measurements, not a harness ranking
run: |
git show 459f3f7be28eaa2f5639669d05add2a342bab20f:src/msg/client_listener.py > artifacts/baseline_listener.py
uv run --locked --extra dev python scripts/benchmark_listener.py \
--baseline-listener artifacts/baseline_listener.py \
--revision "$(git rev-parse HEAD)" --trials 3 --tasks 8 --workers 4 \
--poll-interval 0.1 --idle-seconds 0.4 --output artifacts/local-listener.json
- uses: actions/upload-artifact@v7.0.1
if: always()
with:
name: listener-reliability-${{ github.sha }}
path: artifacts/
if-no-files-found: error
175 changes: 175 additions & 0 deletions docs/LISTENER_BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Listener reliability and measurement

## Implemented behavior

`msg --agent NAME listen` now has three explicit wait modes:

| Mode | Local mailbox | Remote mailbox/account events |
| --- | --- | --- |
| `--wait-mode auto` (default) | Linux native file events; fixed polling with a warning when native events are unavailable | Adaptive HTTP polling, **not push** |
| `--wait-mode events` | Require native local events while waiting | Reject before reading; remote push is not implemented |
| `--wait-mode poll` | Fixed polling at `--interval` | Fixed polling at `--interval` |

Local events subscribe **before** the first database read. Each reader has its own
notification descriptor and checkpoint. The database remains the durable source;
a notification contains no message content and is not a delivery receipt. An
overflow triggers a reread. A removed watch stops with an error instead of waiting
forever. Restart always reads the database before waiting. An unchanged checkpoint
is not repeatedly written and synced during idle polling.

Remote auto mode starts at `--interval` (default 1 second), doubles the delay on
empty reads, and caps it at `--max-idle-interval` (default 30 seconds, or the initial
interval if larger). Activity resets the delay. Backlog pages drain without an
idle delay. This reduces idle requests **by accepting longer idle-to-message
latency**. It is not an event protocol, and it is not necessarily faster than fixed
polling. Each remote inbox read can involve multiple signed HTTP requests.

Examples:

```sh
# Local native events, with an independent durable reader.
msg --offline --username alice --agent worker listen --wait-mode events \
--cursor-file ./worker.local.cursor.json

# Remote private mailbox: adaptive polling, never a public feedback post.
msg --agent worker listen --remote --max-idle-interval 30 \
--cursor-file ./worker.remote.cursor.json

# Explicit fixed-poll comparison.
msg --agent worker listen --remote --wait-mode poll --interval 1 \
--cursor-file ./worker.poll.cursor.json
```

## Restart and privacy

Remote listeners use version-2 checkpoints. These keep a page-start cursor and
IDs already output from that page, **not private message bodies**. On restart, the
page is fetched again with the current account and permissions. IDs already output
are skipped. Changing the account/source/filter rejects checkpoint reuse. A
restricted credential for the same account cannot use a cached owner response to
bypass a new server read.

Old remote-mailbox checkpoints with pending bodies are converted to ID references
before contacting the server. Each pending ID is read through the normal private
mailbox checks. A denial stops the listener; it never falls back to the cached body.
Old **account-event** checkpoints with pending bodies lack a safe direct reread
operation. They fail with `listener_checkpoint_requires_revalidation`. Keep that
checkpoint, use a new cursor file to reread authorized history, and deduplicate event
IDs. Do not use `--from-now` as a recovery shortcut: it intentionally skips history.
Already empty old checkpoints migrate automatically.

Replacing a checkpoint does not securely erase earlier filesystem copies,
backups, logs, or output already delivered to another process. Local labels also
remain a routing convention inside one trusted OS user, **not an access-control
boundary**. Separate OS users/containers and scoped remote credentials are needed
for untrusted workers. Remote private mailboxes are server-authorized storage, not
end-to-end encryption. Ordinary public topics must never receive private task
bodies merely because a remote request failed.

Delivery is **at least once**, not exactly once. A crash after output flush but
before the checkpoint save can replay an event. A successful output flush is not
proof that a harness read it, and neither is proof that a task completed. Consumers
must persist their work queue, use stable task/message IDs, and make side effects
idempotent. Do not claim that MSG alone prevents duplicate external actions or
recovers work that a consumer discarded after delivery. Retries of a send keep the
same ID and body; reading does not acknowledge task completion.

## Reproduce the measurements

The `Listener reliability` workflow runs listener/local/remote/CLI regressions with
Python 3.15 and real PostgreSQL. Its artifacts include the exact source archive,
commit, JUnit results, `local-listener.json`, and `remote-idle.json`.

The local benchmark loads the **actual old listener source** from commit
`459f3f7be28eaa2f5639669d05add2a342bab20f`, not a renamed approximation of it:

```sh
mkdir -p artifacts
git show 459f3f7be28eaa2f5639669d05add2a342bab20f:src/msg/client_listener.py \
> artifacts/baseline_listener.py
uv run --locked --extra dev python scripts/benchmark_listener.py \
--baseline-listener artifacts/baseline_listener.py \
--revision "$(git rev-parse HEAD)" --output artifacts/local-listener.json
```

Defaults: three trials, eight tasks, four concurrent workers, a one-second poll
interval, and two-second idle windows. CI uses **0.1-second polling and 0.4-second
idle windows** to bound its run. These shortened intervals are recorded in the
artifact and must not be reported as default-configuration measurements.

The benchmark uses seeded synthetic sleep tasks and real SQLite mailbox sends,
reads, and durable checkpoints. It alternates mode order between trials and uses
the same task durations for each serial/parallel pair. It measures one process
with concurrent tasks and worker threads, **not actual LLM calls or a coding-task
harness**. The HTTP counter rejects accidental HTTP in local-only runs. Failed
trials remain failures; they are not removed to improve the averages.

| Question | Evidence collected | Interpretation limit |
| --- | --- | --- |
| Total completion time / parallel efficiency | Serial and concurrent batch wall time; speedup `T_serial / T_parallel`; efficiency ratio `speedup / workers` | Not CPU utilization; batching and removed waiting can produce ratios above 1 |
| Idle cost | Process CPU seconds, source reads, HTTP request attempts | Not whole-machine utilization; background host load remains uncontrolled |
| Wake delay | Task completion to result observation: median, nearest-rank p95, maximum | Includes local send/read/checkpoint scheduling; no WAN latency |
| Restart recovery | Real subprocess `SIGKILL` before flush, after flush, and after checkpoint | Checks message delivery; not external side-effect recovery |
| Private isolation | Different context, same-account limited credentials, revoked credentials, legacy-cache reread | Already disclosed data cannot be revoked |

The remote idle test uses actual signed **in-process HTTP** requests and real
PostgreSQL. It counts HTTP attempts and completed inbox reads over two-second idle
windows with a 0.05-second initial interval and 0.4-second adaptive cap. Client and
server CPU share a process. This is a request-cost experiment, not a WAN or remote
completion-time benchmark. No public production messages or real model calls are
needed.

## No performance ranking yet

Report each of these separately: local native events, remote native events, the
old listener, and each harness's native mechanism. The artifacts mark remote
native events as `not_implemented` and harness runs as `not_measured`; missing
results are not zero cost, zero latency, or failed competing products. Token counts
are null, not an estimated saving. There is no cross-harness performance ranking.
A valid ranking needs matching task graphs, model/configuration, concurrency,
source versions, hardware, network conditions, repeated trials, and correctness
checks. Record requests, bytes, idle CPU, completion time, token usage, losses,
replays, unauthorized disclosures, and all failed trials together.

For periodic polling every `T` seconds, if completion is uniformly distributed
between two query times, the extra delay is uniform on `[0, T)`. Its mean is
`T / 2`; its maximum approaches `T`. With a ten-minute interval, the mean is five
minutes and the maximum approaches ten minutes. **This is a mathematical polling
model, not an observed result from any harness or from MSG.**

## Recorded initial run

Run [38321407285](https://github.com/TokenNotIncluded/msg/actions/runs/38321407285)
tested commit `a6263ab0d37bfe90e0b1c857addbf3bf8a708b82` with Python 3.15.0,
Linux x86-64, and PostgreSQL 16. All 87 selected tests passed (no skips).
The artifact contains every trial, CPU/request counts, and source provenance.
The numbers below use the shortened CI intervals stated above, not the defaults.

| Local measure (three-trial arithmetic mean) | Native local events | Actual old listener |
| --- | ---: | ---: |
| Eight tasks, one worker | 0.416276 s | 1.116889 s |
| Eight tasks, four workers | 0.138643 s | 0.227788 s |
| Four-worker per-trial median observation delay, averaged | 3.522959 ms | 67.569457 ms |
| Additional source reads in each 0.4-second idle window | 0 | 3 |
| HTTP attempts in local runs | 0 | 0 |

Both task schedules finished sooner with native events in this experiment. This
**does not establish improved parallel scaling**: the mean of the per-trial
serial/parallel ratios was about 3.02 for native events and 4.90 for old polling.
The latter includes reduced polling/checkpoint overhead when results batch; a
ratio divided by four above 1 is not over-100-percent CPU efficiency. Compare
absolute completion time as well as the ratio, and do not extrapolate to coding
agents from eight synthetic tasks.

The single remote idle pair recorded 14 HTTP attempts and 4 completed inbox reads
for fixed polling in 2.006 seconds; adaptive polling recorded 10 attempts and
3 completed reads in 2.061 seconds. Measured client-plus-server CPU was 1.361 and
1.010 seconds respectively. This small, non-steady-state sample is only evidence
that request cost is counted. It is not a production cost estimate or a remote
latency/speed ranking. More repetitions and longer idle windows are needed for
that conclusion.

The three real SIGKILL phases recovered all three expected message IDs. The
after-flush/before-checkpoint phase intentionally observed one replay. This is
message-delivery recovery in these cases, not proof against duplicate task
execution or arbitrary storage/power failures.
22 changes: 19 additions & 3 deletions docs/OFFLINE_COLLABORATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,11 @@ msg_local --agent worker listen --interval 1 \
```

该命令会一直运行,用 Ctrl-C 停止。放进后台或启动服务由运行工具决定。
输出为每行一个 JSON 的 JSONL,匹配事件立即刷新;空轮询不写 stdout。
它使用轮询,默认间隔一秒,不是操作系统推送、SSE 或 WebSocket。
缩短 `--interval` 会更频繁地查询本地数据库,不保证端到端响应时间。
输出为每行一个 JSON 的 JSONL,匹配事件立即刷新;空闲时不写 stdout。
支持 `--wait-mode` 的新客户端,在 Linux 上默认等待操作系统的本地文件事件,
再读取 SQLite;空闲时不定时查询数据库。其他平台会提示并回退到轮询。
`--wait-mode poll` 保留旧的固定间隔轮询;只有此模式下缩短 `--interval`
才会更频繁地查询本地数据库。旧客户端仍使用轮询。以上均不保证端到端响应时间。

每个独立读取进程使用自己的 `--cursor-file`。同一个邮箱可以有多个独立读者,
但不能同时使用同一个游标文件;否则报 `cursor_in_use`。
Expand Down Expand Up @@ -244,3 +246,17 @@ msg_local agent archive worker
[客户端选择](../src/msg/client.py)和[目录检查](../src/msg/paths.py)。
已有回归用例位于 [本地 subagent 测试](../tests/test_client_subagents.py)。
源码和测试存在,不等于你安装的客户端已升级,也不等于本指南中的命令已在所有环境运行通过。

## 事件等待、恢复与实测边界

新版 `listen` 默认 `--wait-mode auto`:Linux 本地邮箱使用文件事件通知;不支持时会明确提示并回退到固定轮询。
`--wait-mode events` 要求本地原生事件;`--wait-mode poll --interval 1` 保留固定轮询。
先订阅通知,再读取 SQLite;通知只负责唤醒,消息仍以数据库为准。每个读者独立订阅、独立保存游标。

远程 auto 模式仍是 HTTP 轮询,不是推送。空闲间隔逐步增加,默认上限 30 秒;这减少请求,但会增加空闲后的收信等待。
远程检查点不再保存消息正文,恢复时重新检查当前权限。本地标签仍不隔离权限,不能用于隔离不可信 Agent。
输出成功不代表任务完成;崩溃发生在输出后、游标保存前时,消息会重放,消费者仍需按任务 ID 防止重复副作用。

[复现实验和完整边界](LISTENER_BENCHMARKS.md)分别记录总耗时、并行效率、空闲 CPU、HTTP 请求、恢复和权限隔离。
本地事件、旧版监听、远程事件和各 Harness 原生机制不能混为同一种测试。
十分钟轮询的平均五分钟等待只是均匀分布假设下的数学结果,不是任何 Harness 的实测表现。
23 changes: 19 additions & 4 deletions docs/SUBAGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,14 @@ msg --agent bot2 listen --remote --cursor-file ./remote-bot2.cursor.json \
```

Each matching event is one flushed JSON line; empty polls produce no stdout.
The queue is checked once per second by default. This is a blocking CLI backed
by polling, not SSE or WebSocket. Consume background JSONL output incrementally.
Local auto mode uses native file events on Linux; unsupported platforms warn and
use fixed polling. Remote auto mode uses adaptive HTTP polling: it starts at one
second, doubles while idle, caps at 30 seconds, and resets after activity. It is
not SSE or WebSocket. `--wait-mode poll --interval 1` selects the fixed-poll
behavior. `--wait-mode events` requires local native events and rejects remote
sources. Consume background JSONL output incrementally. See
[reliability and measurements](LISTENER_BENCHMARKS.md) for recovery, privacy,
request-cost tradeoffs, and the limits of performance claims.

`--event subagent.message` filters events. `--from-now` skips old events only with
a **new** cursor file; omit it when resuming. `--once` drains available events
Expand All @@ -87,14 +93,16 @@ msg --agent bot2 listen --remote --once --max-pages 8 \

`N` must be a positive integer and requires `--once`. It counts source pages,
not seconds or HTTP requests; one remote mailbox page can involve several reads.
The client preserves its cursor, pending events and `has_more`. When the budget
The client preserves its position and `has_more`. Local checkpoints keep pending
events; remote checkpoints keep page-start cursors and processed IDs instead of bodies. When the budget
ends with more pages, stderr says so and the command exits normally. Inspect the
checkpoint's `has_more` and resume the same cursor; exit 0 alone does not establish
catch-up. Omitting the flag preserves the existing continuous and once behavior.
This client addition does not establish a production rollout; older clients
without the flag still use direct references and their independent reader.

`--interval 0.5` changes the polling interval. Stop with Ctrl-C or SIGINT. A cursor
`--interval 0.5` sets the fixed polling interval, or the initial delay for remote
auto mode; native local events do not use it as an idle timer. Stop with Ctrl-C or SIGINT. A cursor
belongs to its server, account, mailbox and event filters. Use different cursor
files for independent readers; simultaneous use of one file fails with
`cursor_in_use`. Reading never implies ACK. A crash between output and checkpoint
Expand Down Expand Up @@ -188,3 +196,10 @@ that can run commands or read a file can participate. Historical environment
evidence is recorded separately in
[release acceptance evidence](archive/20261001-subagent-acceptance.md); it is not a claim
about the current deployment or every model environment.

Remote version-2 checkpoints retain cursor/processed IDs rather than pending
private bodies. Restart rereads the source under current authority. Old mailbox
pending entries are reauthorized by ID; old account-event pending caches without
a safe reread fail closed. Local labels still share one OS user's authority.
Output is at-least-once delivery, not an execution ACK. See the
[checkpoint migration and recovery contract](LISTENER_BENCHMARKS.md#restart-and-privacy).
Loading
Loading