Skip to content

Commit 8663ecd

Browse files
committed
fix(logstream): start a cursorless watch at the tip, not the start of the log
windows:grok:mempalace flagged the first-run replay on PR #2315. Measured on the real shared brain: a fresh `logstream watch --agent mac-claude` with no cursor woke holding 41 events, the oldest 49 days old. Nothing in the payload marks them stale, so an agent reads July's task.requests as new work — the opposite of the problem this command exists to solve. `latest_event_id()` already documents the right behaviour for the SSE live-tail: capture the tip at connect time "so they receive only post-connect events". The CLI watcher not doing the same made one product with two first-run semantics. - default: a watch with no --since-event-id and no stored cursor starts at the tip. Backlog belongs to the inbox sweep (`logstream list`), which can page it deliberately; a watcher is for what arrives from now on. - never silent: it prints what it skipped and how to get it, on stderr, in --json mode too, so the note cannot corrupt a parsed payload. - --from-start opts back into the replay. Six existing tests turned out to depend on the replay, which is the evidence this mattered. Their intent is filtering, not first-run behaviour, so the shared args helper opts them into --from-start and three new tests cover the tip default explicitly — including that an explicit --since-event-id or a state-file cursor still wins over it. Note for review: test_agent_shorthand_does_not_wake_on_your_own_broadcast asserts exit 2, which the tip default would satisfy for the wrong reason. It opts into --from-start so it keeps testing the exclusion. 4395 passed, 31 skipped.
1 parent 0d3f216 commit 8663ecd

5 files changed

Lines changed: 72 additions & 2 deletions

File tree

integrations/shared/coordination-protocol.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ mempalace logstream watch \
126126
`logstream wait`.
127127
- **`--follow`** keeps going after the first match instead of exiting — use
128128
it for daemons; leave it off for harnesses that wake on process exit.
129+
- **A first watch starts at the tip**, matching the SSE live-tail, and says so
130+
on stderr. Replaying a long fleet log would wake you holding weeks of
131+
history with nothing marking it stale. Backlog is the inbox sweep's job;
132+
pass `--from-start` if you really do want the replay.
129133

130134
Notes that save round trips:
131135

mempalace/cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,9 +1567,25 @@ def _logstream_watch(ls, args, as_json):
15671567
"correlation_ids": normalize_watch_values(args.correlation_id),
15681568
}
15691569
cursor = args.since_event_id or read_watch_cursor(args.state_file)
1570+
skipped_from = None
1571+
if cursor is None and not args.from_start:
1572+
# Start at the tip, like the SSE live-tail does at connect time.
1573+
# Starting from the beginning of a long fleet log means a fresh
1574+
# watcher wakes holding weeks of history and cannot tell it is
1575+
# stale — measured 41 events, the oldest 49 days old, on a real
1576+
# shared brain. Backlog is the inbox sweep's job; a watcher is for
1577+
# what arrives from now on. Never silent: say what was skipped.
1578+
cursor = ls.latest_event_id()
1579+
skipped_from = cursor
15701580
if not as_json:
15711581
where = args.agent or ", ".join(sorted(spec["to_agents"] or [])) or "everything"
15721582
print(f"Watching {where} from {cursor or 'now'}; Ctrl-C to stop.", file=sys.stderr)
1583+
if skipped_from:
1584+
print(
1585+
f"Starting at the tip ({skipped_from}); earlier events are not replayed. "
1586+
"Use --from-start to replay them, or sweep with `mempalace logstream list`.",
1587+
file=sys.stderr,
1588+
)
15731589

15741590
idle_s = args.idle_exit_ms / 1000.0 if args.idle_exit_ms and args.idle_exit_ms > 0 else None
15751591
deadline = time.monotonic() + idle_s if idle_s else None
@@ -3191,6 +3207,14 @@ def _add_logstream_filters(p):
31913207
default=None,
31923208
help="Persist the cursor here so a restart resumes exactly where it stopped",
31933209
)
3210+
p_ls_watch.add_argument(
3211+
"--from-start",
3212+
action="store_true",
3213+
help=(
3214+
"Replay the log from the beginning when there is no cursor "
3215+
"(default: start at the tip, like the SSE live-tail)"
3216+
),
3217+
)
31943218
p_ls_watch.add_argument(
31953219
"--follow",
31963220
action="store_true",

tests/test_cli_logstream.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ def _watch_args(palace, **overrides):
373373
correlation_id=None,
374374
since_event_id=None,
375375
state_file=None,
376+
# These cases seed events and then watch for them, so they opt into
377+
# the replay. The tip default is exercised explicitly by the
378+
# first-run tests below.
379+
from_start=True,
376380
follow=False,
377381
idle_exit_ms=400,
378382
poll_timeout_ms=60,
@@ -528,3 +532,38 @@ def boom(*_a, **_k):
528532
with pytest.raises(OSError, match="broken pipe"):
529533
cmd_logstream(_watch_args(palace_path, agent="mac-claude", state_file=state))
530534
assert not (tmp_path / "watch.json").exists()
535+
536+
def test_fresh_watch_starts_at_the_tip_not_the_beginning(self, palace_path, capsys):
537+
"""A first watch must not replay the whole log.
538+
539+
Measured on a real shared brain, a cursorless watch woke holding 41
540+
events, the oldest 49 days old — and nothing in the payload tells the
541+
agent they are stale, so week-old task.requests read as new work.
542+
The SSE live-tail already starts at the tip; the watcher now matches.
543+
Backlog belongs to the inbox sweep.
544+
"""
545+
cmd_logstream(_append_args(palace_path, to_agent="mac-claude", from_agent="windows-grok"))
546+
capsys.readouterr()
547+
548+
with pytest.raises(SystemExit) as exc:
549+
cmd_logstream(_watch_args(palace_path, agent="mac-claude", from_start=False))
550+
assert exc.value.code == 2, "fresh watch replayed a pre-existing event"
551+
552+
def test_from_start_opts_back_into_the_replay(self, palace_path, capsys):
553+
cmd_logstream(_append_args(palace_path, to_agent="mac-claude", from_agent="windows-grok"))
554+
capsys.readouterr()
555+
556+
cmd_logstream(_watch_args(palace_path, agent="mac-claude", from_start=True))
557+
assert _watch_payload(capsys)["count"] == 1
558+
559+
def test_tip_default_does_not_override_an_explicit_cursor(self, palace_path, capsys):
560+
"""--since-event-id and a state file must still win over the tip."""
561+
cmd_logstream(_append_args(palace_path, to_agent="mac-claude", from_agent="windows-grok"))
562+
first_id = json.loads(capsys.readouterr().out)["id"]
563+
cmd_logstream(_append_args(palace_path, to_agent="mac-claude", from_agent="windows-grok"))
564+
capsys.readouterr()
565+
566+
cmd_logstream(
567+
_watch_args(palace_path, agent="mac-claude", since_event_id=first_id, from_start=False)
568+
)
569+
assert _watch_payload(capsys)["count"] == 1

website/concepts/agent-logstream.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,10 @@ wakes itself on every status it posts. Repeating a filter means "or", which is
174174
how you narrow to the events that genuinely require you and stop being woken by
175175
routine traffic. Exit is `0` on a match and `2` on `--idle-exit-ms`, matching
176176
`wait`'s timeout convention; `--follow` keeps the process alive past the first
177-
match for daemons.
177+
match for daemons. A cursorless first run starts at the tip, like the SSE
178+
live-tail, and says so on stderr — replaying a long fleet log would wake a new
179+
watcher holding weeks of history it cannot tell is stale. `--from-start` opts
180+
into the replay.
178181

179182
`mempalace_event_wait` backs off internally (0.25s → 1s), so a tight retry
180183
loop around it buys nothing. Filter server-side — `to_agent`, `type`,

website/reference/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ mempalace logstream watch --agent mac --type task.request --type patch.ready \
211211
| `append` | Append an immutable event (`--type`, `--stream`, `--room`, `--from-agent` required; `--body`/`--body-file`, `--artifact-id` repeatable) |
212212
| `list` | List events, oldest first (all routing fields as filters, `--since-event-id`, `--limit`) |
213213
| `wait` | Long-poll until a match or timeout (`--timeout-ms`, max 300000; exits `2` on timeout) |
214-
| `watch` | Background watcher: re-arms past the `wait` cap, carries the cursor, and exits `0` on a match / `2` on `--idle-exit-ms`. `--agent ID` is shorthand for `--to-agent ID --exclude-from-agent ID` so your own `*` broadcasts never wake you. Filters repeat to mean "or"; `--state-file` resumes exactly; `--follow` stays alive past the first match |
214+
| `watch` | Background watcher: re-arms past the `wait` cap, carries the cursor, and exits `0` on a match / `2` on `--idle-exit-ms`. `--agent ID` is shorthand for `--to-agent ID --exclude-from-agent ID` so your own `*` broadcasts never wake you. Filters repeat to mean "or"; `--state-file` resumes exactly; `--follow` stays alive past the first match; a cursorless first run starts at the tip (`--from-start` to replay) |
215215
| `ack` | Append an `event.ack` for an event (`--from-agent` required, `--status`, `--body`) |
216216
| `sync` | Pull missing events/artifacts from peer replicas (`--peer URL --token T`, or all peers in `peers.json`) |
217217

0 commit comments

Comments
 (0)