Skip to content

feat(list): report state records with a state filter, and name the failed state - #116

Merged
HammerMei merged 13 commits into
feature/dynamic-watcher-implementationfrom
impl/list-filter
Aug 16, 2026
Merged

HammerMei merged 13 commits into
feature/dynamic-watcher-implementationfrom
impl/list-filter

Conversation

@HammerMei

@HammerMei HammerMei commented Aug 16, 2026 •

Copy link
Copy Markdown
Owner

Increment definition, verbatim from the plan:
impl/list-filter — Read-only view over persisted records; correct on its own,
and useful before the loop lands so idling is observable.

What this changes

list enumerated watcher_configs and derived active from whether a live
processor happened to exist. Under rule-derived watchers neither source
survives: there is no static set of watchers in config.yaml, and idle and
paused rooms — the ones an operator can still act on — have no processor.

So list now reads state records (design §2.8) with a composable state
filter, and the lifecycle gains a fifth state, failed, for the thing that
had no name: a record that wants to be resident and is not.

It lands before the runtime lifecycle deliberately — it is correct on its own,
and it is what makes idling observable once the increment that produces idle
records arrives.

This is a read-only view. It reads records and renders them; it does not
change how watchers start, stop, or persist. That is the plan's definition of
the increment, and the last commit exists to put it back inside those bounds —
see What was reverted, and why below.

failed, and why it is derived

A start writes its record partway through, before the subscription and the
processor. What that leaves is a record which is not paused, not dropped and not
running — and calling it active tells an operator the opposite of what
happened.

  • Derived, not stored. The record says what the gateway wants; residency
    says what is; failed is the two disagreeing. A stored flag needs a writer
    and a clearer, and any path that forgets the clearer leaves a record
    permanently lying about itself.
  • The order is load-bearing. dropped_at is checked before residency,
    because an idle record is supposed to have no processor.
  • Residency includes a transition in flight. pause and reset remove the
    processor first and settle the record last, and reset holds that state for a
    session, a history fetch and a full model turn. Reporting failed there would
    have the recovery verb accuse itself while working.
  • Retried on every daemon start, never remembered as hopeless: suppressing
    future attempts converts a problem the operator can fix into one the system
    has quietly decided to live with. pause is how an operator says "stop trying
    this one".
  • In the default view, because it is the only state that means something is
    wrong.

Also

The merge is stated once. StateStore.save already computed "disk overlaid
by memory"; that is merged_view now, and both save and list call it.

"Is a processor running" is a different question and gets its own accessor.
Asking it through list["active"] is what made idle, paused and no-record-at-all
indistinguishable.

Behaviour changes: a watcher appears exactly when a state record exists for
it — not when it is configured, and not when it started, so no row means
"nothing left to act on" rather than "the start never got far". status asks
for every state explicitly, so its count stays a total. Output is a table with
room id and participants; the participants column is how a group DM is
identified, so it is not behind a verbose flag.

What was reverted, and why

Four review rounds accumulated a write path, a change to save behaviour, a new
piece of runtime state, and a change to how a start inherits its watermark. All
of it came from one decision — fixing pre-existing defects that the new view
merely made visible — and the last commit takes it back out.

The chain, because the review cost sat entirely downstream of it: list shows
more records → the operator verbs disagree with it → hydrate the record so they
agree (a write) → hydrated records can carry a stale room_id → gate the
save's cursor poll → that empties the set at shutdown → add _started to
restore the poll → that breaks nineteen fixtures → add a guard for the fixtures
→ a timing bug is found in _started.

Two facts settle it, both measured rather than assumed:

  • The poll _started existed to restore is inert. unsubscribe_room drops
    the connector's room entry before save_state() runs. With a connector that
    models the real pop: a normal shutdown persists the watermark (the stop-time
    capture does that, and always did), while a failed stop-time capture
    persists nothing — with the save's read working perfectly.
  • The stale-room_id case cannot arise under the design. §2.4: a watcher is
    bound to (connector, room_id), and editing the rule that created it does not
    rebind it. It is a static-path artefact, and the static path goes at cutover —
    so a guard for it is code whose premise expires.

Severity was re-checked against docs/requirements.md, which promises graceful
handling of transient connector failures and no delivery guarantee — so
redelivery after a connector fails mid-teardown is inside what the system claims.

What was found and deliberately not fixed is recorded in #118 (the operator
verbs destroying a disk-only record — the one of the three worth fixing) and
#117 (scheduled-job notification policy). #119 carries the fixture
consolidation the nineteen-test break exposed, and CLAUDE.md gains the rule.

Verification

  • make lint clean; unit + integration 3426 passed.
  • Reverse fault injection, run each round on the then-current tree; the final
    round before the revert caught 25/25 and 28/28. Baseline printed green first;
    every injection reverted with the inverse edit, never git checkout;
    __pycache__ cleared around each run.
  • The gap that mattered most had no test at all: request["states"] becoming a
    StateFilter is the only join between the CLI and the reader, and both halves
    were tested in isolation — so mutating that one call to ignore the request
    passed the entire suite while the daemon silently answered every query with
    the default.
  • Several fixtures were wrong in ways that made tests pass for the wrong reason:
    ScriptConnector returns the same string for a room's id and name; a watcher
    configured with the default agent could not distinguish a config lookup from
    a global fallback; and a record in _states with no processor is a state no
    stop path produces.

`list` enumerated `watcher_configs` and derived "active" from whether a live
processor happened to exist.  Under rule-derived watchers neither source
survives: there is no static set of watchers in config.yaml, and idle and
paused rooms — the ones an operator can still act on — have no processor.

So `list` now reads records (design §2.8) and takes a composable state filter
defaulting to active + paused, with `--active/--idle/--paused/--all` on the
CLI.  Output becomes an aligned table carrying the room id and the
participants, which is how a group DM is identified and therefore not
something to hide behind a verbose flag.

Three decisions worth naming:

* **The merge is stated once.**  `StateStore.save` already computed "disk
  overlaid by memory"; that is extracted as `merged_view` and both save and
  list use it, so the set an operator is shown is the set that would be
  persisted.
* **The state derivation is a named function with one answer per case**, and
  paused outranks dropped: a record that is both is still awaiting a human,
  and reporting it as idle would hide it from the default view.
* **"Is a processor running" is a different question** and gets its own
  accessor.  Tests that were asking it through `list["active"]` now ask
  `get_processor`.

Consequences, deliberate: a configured watcher with no record no longer
appears (it has no session, no watermark and nothing to pause — the failure
is reported by startup), and `status` asks for every state explicitly so its
count stays a total.

Authored-By: Hammer Mei (铁锤老妹🔨)
Nine injected faults, nine caught — except one, and it was the test's fault:
`test_a_started_watcher_records_the_resolved_room_name` passed with
`room_name=room.name` deleted, because ScriptConnector returns the same
string for a room's id and its name, so the assertion was satisfied by the
`room_name or room_id` fallback alone. The room now resolves to a distinct
id and name, and deleting the write fails it.

The rest are things re-reading found, which injection is blind to:

* **The empty-list message restated the server's default.** `No active/paused
  watchers` hardcoded what OPERABLE means in a second place; it now points at
  `--all` without naming the set.
* **The session id goes back into the table.** Dropping it left no surface an
  operator can read one from short of opening state.<connector>.json, and its
  remaining use is being pasted into a backend's own resume command.
* **A test name claimed more than its condition.** "A blocked agent is not
  listed" is only true with no prior record — the neighbouring test proves the
  opposite case — so the name now says so.
* **The alignment assertion was a tautology** (`row.startswith(row[:n])`).
* `parse_state_filter` loses an `assert` that could not fire, and the column
  widths no longer depend on the row list being non-empty.
* `docs/architecture.md`'s "is the watcher running" step said STATE answers a
  question it does not: STATE describes the record, and on the static path a
  start that failed after its record was written still reads `active`.
* `scheduling-context.md` told agents to find watcher names with `list`, which
  now hides idle ones.

Authored-By: Hammer Mei (铁锤老妹🔨)
…false prose

Findings from a "dead code and duplicated rules" pass and a "read every string
as an instruction" pass. Every one was either a rule stated twice or a claim
that was not true of the code.

**Rules stated twice**

* `_agent_name_for` walked `_watcher_configs` a third time.
  `_require_watcher_config`'s docstring records that the two lookups over that
  list were collapsed into one — this made that note false. It layers on
  `get_watcher_config` now, which is the shape that note describes.
* `["active", "idle", "paused"]` was written twice in `cli.py` — `status` and
  `--all` — so a fourth state would have silently left `status` reporting a
  "total" that omits it. One `_ALL_STATES` now.
* `room_kind` left the row: nothing read it, not the table and not the control
  server. Shipping a field with no caller is the shape this series has been
  corrected on twice.

**Claims that were not true**

* **"A first start that failed leaves no record" is wrong.** `_start_watcher`
  writes the record at step 3, and the subscribe/claim rollback keeps it *on
  purpose* so the session id and injection flag survive the next attempt. Only
  failures before that point leave nothing. An operator following the old
  sentence would read a present row as "it started fine" and hunt the
  connector. `list_watchers`' own docstring had the qualifier; both docs had
  dropped it.
* **"…until the daemon is restarted" prescribed a remedy that cannot work.**
  The record is on disk, so the next boot re-reads it, fails the same way and
  reports `active` again — burning the restart as a diagnostic.
* **"Nothing to pause" is false**, and it hid the one command that helps: the
  record `pause_watcher` creates is what makes `sync_watchers` skip the watcher
  at boot, which is how one that fails every boot is stopped.
* **`get_processor`'s docstring blamed a defect that never existed.** Paused
  was always distinguishable — the old row carried it from the record, and
  `active` from the processor, separately.
* **`scheduling-context.md` pointed agents at the wrong authority.** `list`
  reports records; `schedule create` resolves against config, so an agent told
  "every watcher the gateway knows about" would report a schedulable watcher as
  non-existent. This file is read by agents at runtime.
* `lifecycle_state` described `dropped_at` in the present tense though nothing
  writes it yet; the empty-list comment claimed a discipline the code does not
  follow; `merged_view`'s "save writes exactly this" is false whenever `prune`
  is passed; and `_line`'s rstrip comment named a case that cannot occur.

**Behaviour**

`list` no longer prints "no watchers, try --all" when the daemon returned a
hard failure — an unknown `--connector` has no `errors` list, so the empty
branch was answering a query that never ran. The sample output in
`docs/install-agent.md` is now what `_print_watcher_table` renders rather than
hand-aligned.

Authored-By: Hammer Mei (铁锤老妹🔨)
…verbs reach it

Two owner decisions, and the design doc carries both before the code does —
because the alternative is discovering the state machine one review round at a
time, which this branch has already paid for once.

**A fifth lifecycle state, `failed`** (design §2.5, new section). A start writes
its record partway through, before the subscription and the processor, so that
a later failure does not lose the session id or the injection flag. What that
leaves is a record which is not paused, not dropped and not running — and
calling it `active` tells an operator the opposite of what happened. The design
had already noticed the shape from the other side, while arguing that
`dropped_at` cannot be inferred: "the subscribe-failure rollback deliberately
keeps a record with `room_id` populated, so a start failure is
indistinguishable from a healthy record by that field."

Three things about it worth keeping:

* **Derived, not stored.** The record says what the gateway wants; residency
  says what is; `failed` is the two disagreeing. A stored flag would need a
  writer *and* a clearer, and any path that forgot the clearer would leave a
  record permanently lying about itself. Derived, the next successful start
  clears it with nobody having to remember to.
* **The order is load-bearing.** `dropped_at` is checked before residency,
  because an idle record is *supposed* to have no processor; reversing the two
  would report every idle watcher as failed.
* **Residency is a parameter, not a lookup**, so the predicate stays pure: a
  tool reading state files outside a running daemon passes `False` and gets the
  honest answer.

Retried on **every daemon start** rather than remembered as hopeless. Recording
the failure and suppressing future attempts converts a problem the operator can
fix into one the system has quietly decided to live with, and needs a persisted
marker whose staleness is a second bug. `pause` is how an operator says "stop
trying this one" — which is exactly what pause means, as against the system
deciding it. `failed` is in the default view for the same reason it exists: it
is the only state that means something is wrong.

**`pause`, `resume` and `reset` now act on the records `list` shows.** They read
only what this process had loaded, so a watcher whose agent was unavailable at
boot — visible in `list`, with its session id — was mutated by writing a blank
record over the persisted one: session id unrecoverable, watermark reset,
messages redelivered next start. `_hydrated_state` pulls the persisted record in
first, and hydrates rather than merely reading because `save` persists
`self._states`: a record mutated outside that map would not be written, and the
mutation would look like it had worked.

**Tests.** The wire→filter join had no coverage at all — `request["states"]`
becoming a `StateFilter` is the only place the CLI meets the reader, and both
halves were tested in isolation, so mutating that one call to ignore the request
passed the entire suite while the daemon silently answered every query with the
default. Also fixed: a wire-name enumeration that enumerated a copy of the
members rather than the type; a merged-view assertion that compared the method
under test with itself; a room-name assertion satisfied by the watcher name; an
agent-fallback assertion satisfied by the global default; and a fixture whose
`room_name: ""` the server cannot emit.

Authored-By: Hammer Mei (铁锤老妹🔨)
…s branch

Two review lenses — "does `failed` hold for every reachable record" and "read
every string as an instruction". Both found P1s, and the two worst were created
by this branch's own changes.

**`pause` and `reset` reported themselves as `failed` while running.** Both
remove the processor first and settle the record last, so in between the record
is `paused=False`, no `dropped_at`, no processor — byte-identical to what a
failed start leaves. `pause` holds that for the drain timeout; **`reset` holds
it for a fresh session, a history fetch and a full model turn**, bounded by the
agent timeout and defaulting to minutes. A concurrent `list` — another terminal,
or an agent running `list` from its own room — saw `failed`, which the docs
define as *the* state meaning something is broken, and sent the operator to a
startup log with nothing in it. The recovery verb accused itself while working,
and the design doc claimed this window was unreachable.

Residency now means "a processor is registered **or** a lifecycle transition is
in flight". The per-watcher lock is exactly that span, including releasing on
failure, so a genuinely failed start still reports `failed` the moment it gives
up. Erring toward `active` for a few seconds is self-correcting; erring toward
`failed` sends someone hunting.

**A hydrated record could steal another room's watermark**, and there were two
sites, not one. Loading a persisted record on demand made stale `room_id`s
reachable, and both `_stop_processor` and `StateStore.save` read the connector's
cursor for whatever room a record names. That cursor belongs to the *room*: for
a watcher moved to another room in config, it is now some other watcher's
progress. Copying it in handed this watcher a position it never reached, which
its next start restored onto the room it really watches — silently discarding
everything below it.

`StateStore.save`'s own docstring already stated the invariant ("only the live
records are polled; a record read back from disk has no connector-side room
state to consult") — hydration broke exactly that premise, so it is now a
parameter (`serving`) instead of something the caller happens to guarantee.
`_start_watcher` no longer inherits a watermark from a record naming a different
room either, which is the same rule `_provision_session` already applies to the
session id.

**Two operator instructions were inverted.** `architecture.md` said restarting
is not a fix — but agent availability is decided once at boot and both `resume`
and `reset` refuse fail-closed on it, so a restart is the *only* recovery for
the commonest cause of `failed`. And `scheduling-context.md`, which agents read
at runtime, said scheduling against an unlisted watcher "still works": creation
succeeds because it validates against config, and every fire is then a silent
no-op, because a watcher with no record has no processor.

**The before/after-the-record model was wrong**, found independently by both
lenses: three of the four rollback paths delete the record again, on purpose, so
a context-injection or attachment-workspace failure leaves no row at all. The
docs now carry the actual table, and the short form — a row means there is state
to act on; no row does not mean the start never got far.

Smaller: `list_watchers`' docstring promised a `room_kind` key the same commit
removed; "a DM carries no name" is false on both connectors (`resolve_room`
returns the configured `@handle`) so the fallback's stated reason and a CLI
fixture were both wrong; `merged_view` claimed an equality that holds in neither
direction; a non-iterable `states` escaped as a `TypeError` that read as a
broken daemon rather than a bad request; and the stop-path watermark tests used
a fixture — a record in `_states` with no processor — that no stop path
produces, so one of them was passing for the wrong reason.

Authored-By: Hammer Mei (铁锤老妹🔨)
Reverting the `TypeError` half of the filter's error handling changed nothing:
25 tests stayed green. The arm was added in the same edit as its reasoning and
never got an assertion — the shape a fix-and-test-in-one-edit always leaves, and
the only thing that has ever caught it here is injecting the fault and watching
nothing fail. Reading the diff does not.

Authored-By: Hammer Mei (铁锤老妹🔨)
@HammerMei

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73965e3293

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/user-guide.md Outdated
Comment on lines +676 to +677
| Context injection, the attachment workspace, or session binding | **no row** — these roll the record back |
| The room could not be resolved, the session could not be created, or the agent was unavailable on a first-ever start | **no row** — nothing was written |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify the no-row cases for previously persisted watchers

These failures produce no row only on a first-ever start. If the watcher already has a persisted record, _start_watcher removes only its new in-memory state on context, workspace, or binding failure—and does not touch memory at all on room-resolution or session-creation failure—after which sync_watchers() calls StateStore.save(), whose merged view preserves the old disk record. The new record-based list therefore reports that watcher as failed, contrary to this supposedly exhaustive table; operators could consequently misinterpret a valid failed row or expect it to disappear. Qualify all of these cases based on whether a prior record exists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and this is the third round on this same paragraph — which is the finding rather than the wording. Rollbacks remove only what this start added, so a watcher that ran on an earlier boot keeps its disk record through merged_view and shows failed, while a first-ever start with the identical fault shows nothing.

Rather than qualify each row, the operator-facing texts now state the invariant: a watcher appears exactly when a state record exists for it — not when it is configured, not when it started. Two consequences are called out explicitly: no row means "nothing left to act on", not "the start never got far"; and a failed row may be weeks old rather than a fresh failure.

The design doc keeps a per-failure table, because an implementer does need it, with the first-start caveat written next to it. Fixed in 4d75f4f (docs/user-guide.md, CHANGELOG.md, docs/design/dynamic-watcher-design.md).

Comment thread gateway/cli.py Outdated
Comment on lines +483 to +484
if isinstance(value, list):
value = ", ".join(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render non-string participant entries safely

When a persisted record contains a non-string participant, load_state() accepts it because the schema check validates only that participants is a list, but this new formatter passes the elements directly to str.join; for example, participants: [1] raises TypeError and makes the entire list command crash instead of displaying the other valid watcher rows. Either validate the element type while loading state or stringify each participant before joining.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and ranked higher than P2 on consequence: ", ".join on a non-string loses every row from every connector, not one cell — and this loader has now been corrected seven times for a value read without checking it is the type the reader assumed. Ranked lower than P2 on reachability, since nothing writes participants yet. Net: fixed at both layers rather than choosing between them.

  • Loader — an element check derived from the annotations via get_args, not special-cased for participants, so a second sequence field cannot be added without its elements being checked. A test enumerates every sequence-typed field on WatcherState and asserts each rejects a wrong element type.
  • Formatter — stringifies. The CLI reads rows off a socket rather than parsing the state file, so a daemon on a different version can still hand it anything; a display function must never be what takes the command down. Tested directly with participants: [1, None, "@alice"].

One thing the fix turned up: next(..., None) cannot signal "no bad element", because None is itself a value a payload can contain — participants: [None] would have passed. Sentinel is an object(), with a test for that case.

Fixed in 4d75f4f and 9767b6a.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: half of this fix has been reverted.

Kept — the CLI formatter stringifies. The CLI reads rows off a socket rather than parsing the state file, so a daemon on a different version can hand it anything, and a display function must never be what loses every other connector's rows. Tested with participants: [1, None, "@alice"].

Reverted — the loader's derived element-type check. Re-ranked on reachability rather than consequence: nothing writes participants yet, so the check guards a field with no producer, and it is loader hardening rather than part of the read-only view this increment is scoped to. Under this repo's own severity rules that makes it real but not urgent, and building it now is a guard whose premise has not arrived.

It should land with the increment that starts writing participants (impl/runtime-lifecycle), where a test can exercise a real producer instead of a hand-written record. Reverted in acbe308.

Comment thread gateway/contexts/scheduling-context.md Outdated
You can schedule recurring or one-time tasks using the `agent-chat-gateway schedule` CLI. When a user asks you to set up a recurring task, reminder, or automated job, use these commands.

> **IMPORTANT — watcher name**: The `<watcher>` argument must be the **exact** watcher name from this gateway's configuration. Do NOT invent or guess a name. If you are unsure of the correct watcher name, run `agent-chat-gateway list` first to see all configured watchers, then use the name shown there.
> **IMPORTANT — watcher name**: The `<watcher>` argument must be the **exact** watcher name from this gateway's configuration. Do NOT invent or guess a name. If you are unsure of the correct watcher name, run `agent-chat-gateway list --all` first and use a name shown there. **If a name is absent from `list`, do not schedule against it.** Job creation would be accepted — it validates against config — but nothing delivers: a watcher with no row has no running processor, so every fire is a silent no-op recorded only in the gateway log. Tell the user the watcher is not running and needs to be fixed first, rather than creating a job that will never fire.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an active watcher before recommending scheduling

list --all also returns paused, idle, and failed records, none of which has the processor required by JobScheduler._inject(), so merely choosing a displayed name does not establish that scheduled messages can be delivered. A paused watcher skips every fire until resumed, while idle or failed watchers take the injection-failure path; this guidance should tell the agent to require an active row (for example, by using list --active) rather than treating presence in the all-states view as sufficient.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half agreed — the guidance was wrong, but requiring --active is the wrong correction, and my original wording was wrong in the other direction too.

Tracing _fire_job (gateway/core/scheduler.py:358-400) rather than assuming:

  • A paused watcher's fire is skipped with an explicit "expected" log and retried at the job's next occurrence; a finite job's run_count is not consumed (:380-382).
  • A non-paused failure additionally posts a ⚠️ notice into the room via _notify_injection_failure, so it is not the silent no-op my text claimed.
  • Idle is a non-issue by design: §2.8 requires get to recreate an idle watcher transparently precisely because scheduled work has no other route back. It also cannot occur today — nothing writes dropped_at.

So refusing on anything but active would block two legitimate cases the repo owner named: an ops pause for maintenance with a reminder set for after it, and a transient failure. Both work today.

What does need saying is when the retry lands, which varies enormously — computed rather than assumed:

job next retry after a failed fire
--every 5m --times 1 5 minutes, and every 5 minutes until it gets through
--every 1w next week
--starting "YYYY-MM-DD HH:MM", no --every the same date next year

That last one is why the guidance now asks the agent to inform and confirm rather than refuse: a one-shot whose only fire lands inside an outage effectively never happens, and the user should get to decide. Fixed in 4d75f4f.

Separately noted for its own issue, not this PR: --every 5m --times 1 against a failed watcher posts that ⚠️ into the room every five minutes indefinitely — there is no retry cap.

…e the table total

Three findings, re-ranked by consequence rather than adopting the badges.

**`participants` with a non-string element took down the whole command.**
`load_state` checks that the field is a `list` and stops there, so
`participants: [1]` loads, and the CLI's `", ".join(...)` then raises — losing
every row from every connector, not one cell. Ranked above Codex's P2 on
consequence and below it on reachability (nothing writes `participants` yet),
so it is fixed at both layers rather than either:

* the loader gains an **element** check, derived from the annotations
  (`get_args`) rather than special-cased, so a second sequence field cannot be
  added without its elements being checked. A test enumerates every
  sequence-typed field on `WatcherState` and asserts each rejects a wrong
  element type;
* the formatter stringifies. A display function is the wrong place to discover
  bad data, and it must never be the thing that takes the command down.

**The "which failures leave a row" table was wrong, for the third time**, and
that is the finding. Rollbacks remove only what *this* start added, so a watcher
that ran on an earlier boot keeps its disk record and shows `failed` where a
first-ever start shows nothing — the same fault, presenting two ways. Rather
than qualify every row, the operator-facing texts now state the invariant: **a
watcher appears exactly when a state record exists for it.** The design doc
keeps the per-failure table, because an implementer does need it, with the
first-start caveat written next to it.

**Scheduling guidance was wrong in the other direction — mine, not Codex's.**
Codex asked for `list --active` as a precondition. That would refuse two
legitimate cases the owner named: an ops pause during maintenance with a
reminder set for after it, and a temporary failure. Traced the scheduler
instead, and the previous wording ("every fire is a silent no-op") was also
wrong: a paused watcher's fire is skipped and retried at the next occurrence
with a finite job's run count **not** consumed, and a non-paused failure posts a
notice into the room. What varies is when the retry lands — verified by
computing it: `--every 5m --times 1` retries every five minutes, `--every 1w`
next week, and a one-shot anchored to a datetime retries **the same date next
year**, which for a single fire inside an outage means it effectively never
happens. The guidance now says that, and asks the agent to inform and confirm
rather than refuse.

Also swept the word "shape" from this branch's prose where it meant "kind of
defect" — a habit of mine, not a project convention (the repo's own CLAUDE.md
uses it zero times), and one the owner has to spend effort decoding.

Authored-By: Hammer Mei (铁锤老妹🔨)
…fused

The `str(v)` in the table formatter shipped without a test — the same omission
injection caught one commit ago, made again in the commit that recorded it.

It is not redundant with the loader's new element check: the CLI reads rows off
a socket rather than parsing the state file, so a daemon on a different version
can hand it anything. The loader stops bad data getting persisted; this stops
bad data costing every other connector its rows.

Authored-By: Hammer Mei (铁锤老妹🔨)
@HammerMei

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9767b6aab4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread gateway/contexts/scheduling-context.md Outdated
> | `active` | Delivered normally. |
> | `idle` | Fine — the watcher is woken on demand. |
> | `paused` | Skipped quietly and retried at the job's **next scheduled occurrence**. A finite job's remaining run count is **not** consumed. |
> | `failed` | Same retry, and each missed delivery also posts a ⚠️ notice into the room. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify notices for disk-only failed watchers

For a watcher with a persisted record whose agent is unavailable at this boot, sync_watchers() skips it without adding it to _states, while the merged record view still lists it as failed. When its scheduled job misses, _notify_injection_failure() calls notify_watcher_room(), but that method reads only the in-memory state and returns without sending when the record is disk-only. Thus this newly documented guarantee that every failed delivery posts a notice is false for a failed state users can actually select from list --all; either hydrate the record for notification or qualify this row.

Useful? React with 👍 / 👎.

@HammerMei HammerMei Aug 16, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and thank you — this is the fourth site of a rule the previous commit fixed at three others, so I swept the rest rather than patch this one.

notify_watcher_room resolves the room through get_watcher_state, which reads the in-memory map only (gateway/core/session_manager.py:265), so a blocked-agent record — precisely the one list shows as failed — resolves to nothing and the notice is dropped.

Sweep of every other reader of watcher state, neither affected:

  • inject_message (:215) only reads state after establishing a processor, so the record is in memory by construction.
  • the scheduler's is_paused check (gateway/core/scheduler.py:361) is safe, because sync_watchers hydrates a paused record at watcher_lifecycle.py:142-145 — before the agent-availability check at :150-159 — so a paused record is never disk-only.

Taking the "qualify" option rather than "hydrate", deliberately. Hydrating would make the notice fire, but a disk-only record can still name a room the watcher has since moved away from — the same stale-room_id class this branch just fixed for watermarks in _stop_processor and StateStore.save — and posting a scheduling alert into a room where nobody set a reminder is worse than posting none. That makes it a notification-policy decision rather than a one-line fix, and it now sits on #117 next to the uncapped-repeat question, which is the same subject: this path notifies forever for a loaded watcher and never for an unloaded one, and both read as failed.

The agent-facing row now says the notice is not guaranteed and tells the agent not to promise it. Current behaviour is pinned by TestNotifyWatcherRoomNeedsLoadedState so changing it later is a decision rather than an accident.

Fixed in 3999000.

Codex, round two. The row I added last commit promised that every missed
delivery for a `failed` watcher posts a notice into the room. It does not:
`notify_watcher_room` resolves the room through `get_watcher_state`, which reads
the in-memory map only, so a record this process never loaded — the blocked-agent
case, which is exactly what `list` shows as `failed` — resolves to nothing and
the notice is silently dropped.

This is the fourth site of the rule the last commit fixed for `pause`, `resume`
and `reset`: a reader of watcher state must see the same set `list` shows. So I
swept the rest rather than patching this one. Two other external readers exist
and neither is affected: `inject_message` only reads state after establishing a
processor, and the scheduler's `is_paused` check is safe because `sync_watchers`
hydrates a paused record *before* the agent-availability check, so paused is
never disk-only.

**Not fixing the behaviour here, deliberately.** Hydrating for the notification
would make it fire, but a disk-only record can name a room the watcher has since
moved away from — the same stale-`room_id` class this branch just fixed for
watermarks — and posting a scheduling alert into a room nobody set a reminder in
is worse than posting none. That is a notification-policy decision, and it now
sits on #117 alongside the uncapped-repeat question, which is the same subject.

The agent-facing text is corrected instead, and the current behaviour is pinned
by tests so that changing it later is a decision rather than an accident.

Authored-By: Hammer Mei (铁锤老妹🔨)
@HammerMei

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3999000b4b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread gateway/core/watcher_lifecycle.py Outdated
def save_state(self) -> None:
"""Persist current state (called before shutdown)."""
self._state_store.save(self._states)
self._state_store.save(self._states, serving=set(self._processors))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry watermark capture after a failed stop

When get_last_processed_ts() raises transiently during shutdown, _stop_processor() has already removed the watcher from _processors and aborts before copying its live cursor; stop_all() then swallows that exception, and this serving set is empty, so StateStore.save() no longer makes the retry that the previous implementation made. The stale persisted watermark can therefore cause already accepted messages to be redelivered after restart. Keep watchers whose stop failed eligible for the final watermark pull, or make the capture failure non-fatal inside _stop_processor().

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and ranked above P2 — this is a regression I introduced last round, not a pre-existing gap, and it is silent.

The failure is worse than the transient-raise case you describe. shutdown() calls stop_all() and then save_state(), so by the time the final save runs self._processors is empty — meaning serving was empty and the shutdown save polled no room at all, for any watcher. That poll is the backstop for a watcher whose own capture failed, and losing it silently redelivers messages after a restart, reopening the class this series' fix/watermark-before-unsubscribe closed deliberately.

The mistake was the predicate. "Has a processor right now" is not what makes a record's room_id trustworthy enough to read the connector's cursor for — "this process started it" is, because then we resolved that room ourselves and subscribed to it. And unlike a processor, that survives the stop. _started is now the serving set, never cleared on stop, with the reason written next to it.

Also took your second option in addition: the stop-path read is now best-effort, as the same read already is in StateStore.save. One value read in two places should not have two failure policies, and aborting there skipped the unsubscribe and the drain in order to preserve a watermark.

Three tests: the final save polls a stopped watcher; a hydrated record is never polled; a raising cursor read does not abort the stop. Fixed in 367c9d4.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: this fix has been reverted, because the thing it restored does not exist.

Your finding was right about the mechanism — the shutdown save's serving set really was empty — but I accepted "and therefore the retry is lost" without checking whether that retry could ever succeed. It cannot. unsubscribe_room drops the connector's room entry (rocketchat/connector.py:1031) before save_state() runs, so the save's read returns None for every stopped watcher.

Measured with a connector that models the pop, rather than reasoned about:

normal shutdown                          -> persisted: 'T-100'
stop-time capture raises (save read fine) -> persisted: ''

The stop-time capture is what persists the watermark, and always was — that is what fix/watermark-before-unsubscribe established. There was no second chance to remove.

So _started, the serving parameter, and the _hydrated_state write that made them necessary are all gone. The whole chain was downstream of fixing a pre-existing defect inside an increment the plan defines as a read-only view over persisted records — that defect is now #118 instead.

Reverted in acbe308.

Comment thread gateway/contexts/scheduling-context.md Outdated
> | `idle` | Fine — the watcher is woken on demand. |
> | `paused` | Skipped quietly and retried at the job's **next scheduled occurrence**. A finite job's remaining run count is **not** consumed. |
> | `failed` | Same retry. A ⚠️ notice is posted into the room **only if the gateway loaded that watcher this run** — one whose agent was unavailable at startup never did, so its misses are silent. Do not promise the user they will be told. |
> | not listed at all | It has never started; treat it as `failed`. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid claiming every absent watcher never started

A missing row proves only that no state record survived, not that startup never began: _start_watcher() can create a session and progress through context, workspace, or binding work before rolling its newly written record back. The corrected rule in docs/user-guide.md:674-685 explicitly distinguishes those cases, so this scheduling guidance can make the agent give users a false diagnosis; describe the watcher as having no surviving record and treat it as failed without asserting how far startup got.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the row overclaimed, and docs/user-guide.md had already been corrected on exactly this point, so the two were inconsistent within the same PR.

It now reads: no state record survived; treat it as failed — but do not tell the user it never started: a start can create a session and get well into setup before rolling its record back. Fixed in 367c9d4.

Comment thread gateway/core/state.py
if names is None:
return StateFilter.OPERABLE
result = StateFilter(0)
for name in names:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-list state-filter payloads

When a hand-written control-socket client sends a JSON object such as {"states": {"idle": false}}, this loop iterates the object's keys and accepts the request as an idle filter even though the wire contract requires a list. The caller therefore receives a successful answer to a malformed and potentially different query, contradicting the function's stated fail-loud behavior; validate that names is a list before iterating it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the docstring above it claimed fail-loud, which made it worse than a silent gap — the function advertised the behaviour it did not have.

parse_state_filter now requires a list. Worth noting the same check rejects a bare string, which was the more likely accident: "idle" iterates to its characters, so it would have failed as five unknown names — the right outcome for the wrong reason — and "" would have read as an empty selection. Both cases are tested. Fixed in 367c9d4.

… of my own

**The watermark backstop, and it was mine.** Keying the save-time cursor poll on
"has a processor right now" looked equivalent to the old unconditional poll. It
is not: `shutdown` stops every processor and *then* saves, so at the one save
that matters the set was empty and nothing was polled at all. That poll is the
backstop for a watcher whose own capture failed, and removing it silently
redelivers messages after a restart — reopening the class of bug PR 2 of this
series (`fix/watermark-before-unsubscribe`) existed to close. Ranked P1 above
Codex's P2: silent, on every shutdown, and a regression rather than a gap.

The right predicate was never "has a processor" but **"this process started
it"**: that is what makes a record's `room_id` trustworthy enough to read the
connector's cursor for — we resolved and subscribed to that room ourselves — and
unlike a processor it survives the stop. A record merely loaded from disk (a
hydrated one, or a paused one seeded at boot) may name a room the watcher has
since been moved away from, which is the case the `serving` set exists to
exclude.

Also made the stop-path cursor read best-effort, as the same read already is in
`StateStore.save`. One value read in two places must not have two failure
policies, and aborting there skipped the unsubscribe and the drain in order to
preserve a watermark.

**Two smaller ones.** `parse_state_filter` accepted anything iterable, so
`{"states": {"idle": false}}` iterated the object's *keys* and returned a
confident `idle` answer to a request that asked the opposite; it now requires a
list, which also rejects a bare string before it decomposes into characters. And
the scheduling guidance claimed an absent row means the watcher never started —
a rollback can create a session and get well into setup before removing its
record, so it now says only that no record survived.

**Fixture fallout, and the guard for it.** `_started` broke nineteen tests at
once: all hand-built `WatcherLifecycle.__new__` objects missing a field no real
lifecycle can lack. Rather than only patching them, a test now derives the
**bookkeeping** fields from `__init__` — those assigned an empty container, which
have no external source and so must be supplied by any hand-built object — and
asserts every fixture sets them. Collaborators are excluded deliberately: a
fixture substitutes doubles for exactly the ones its path needs, and demanding
all of them would make the check a nuisance instead of a guard. It immediately
found seven more fixtures missing `_blocked_agents` or `_watcher_locks`.

The guard's own self-check ("does this find `_started`?") failed first time
round, because half the constructor uses annotated assignment and the walk only
handled `ast.Assign`.

Authored-By: Hammer Mei (铁锤老妹🔨)
@HammerMei

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 367c9d4dfc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread gateway/core/watcher_lifecycle.py Outdated
backend_identity=identity,
)
self._states[wc.name] = ws
self._started.add(wc.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark the watcher started only after subscription succeeds

When startup fails after this line, _started permanently contains a watcher that never successfully subscribed. In particular, subscription rollback deliberately retains ws, so the subsequent sync_watchers() save treats the failed watcher as serving and polls its room cursor; if the connector left partial room state or cleanup failed, that cursor can represent messages that were never routed to this processor, and restoring it on the next boot silently skips them. Add the name only after subscription and dispatcher registration succeed, while continuing to retain it across later normal stops.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: moot — _started no longer exists.

Your timing analysis was correct, and I fixed it as reported. It has since been reverted along with the mechanism it belonged to: _started was introduced to restore a shutdown watermark poll that measurement showed is inert (see the thread on watcher_lifecycle.py:475), so the field, its early-marking bug, and the nineteen fixture edits it forced are all gone.

Worth recording that this finding is what made the pattern visible: three consecutive rounds landed findings in the previous round's fix, which is the signal that the approach rather than the code was producing them. Reverted in acbe308.

Comment thread docs/architecture.md Outdated
Comment on lines +956 to +957
(plain `list` hides idle watchers). No row means no state record: the
watcher never started, and the reason is in the startup log.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the false never-started diagnosis

When context injection, attachment workspace setup, or session binding fails, startup may have already created a session and performed substantial work before rolling back the record, so a missing row does not establish that the watcher never started. Fresh evidence in the current tree is the immediately following troubleshooting step, which explicitly describes these rollback cases and says the opposite; leaving both statements adjacent can send operators to the wrong diagnosis.

Useful? React with 👍 / 👎.

… defines

The plan defines `impl/list-filter` as "a **read-only** view over persisted
records; correct on its own". What had accumulated was a write path, a change to
save behaviour, a new piece of runtime state, and a change to how a start
inherits its watermark — none of which this increment needs, and all of which
came from one decision: fixing pre-existing defects that the new view merely
made visible.

The chain is worth naming, because four review rounds sat downstream of it:
`list` shows more records → the operator verbs disagree with it → hydrate the
record so they agree (**a write**) → hydrated records can carry a stale
`room_id` → gate the save's cursor poll on who is serving → that empties the set
at shutdown → add `_started` to restore the poll → that breaks nineteen fixtures
→ add a guard for the fixtures → Codex finds a timing bug in `_started`.

Two facts settle it, and both were checked rather than assumed:

* **The poll `_started` existed to restore is inert.** `unsubscribe_room` drops
  the connector's room entry before `save_state()` runs, so the save's read
  returns `None`. Measured with a connector that models the pop: a normal
  shutdown persists the watermark (the stop-time capture does that, and always
  did), while a *failed* stop-time capture persists nothing — with the save's
  read working perfectly. There was never a backstop to lose.
* **The stale-`room_id` case cannot arise under the design.** §2.4: a watcher is
  bound to `(connector, room_id)` and editing the rule that created it does not
  rebind it. A record naming a room its watcher does not serve is a static-path
  artefact, and the static path is removed at cutover — so a guard for it is
  code whose premise expires, which this plan has a standing rule against.

Severity was also wrong, and re-checked against `docs/requirements.md`: it
promises graceful handling of transient connector failures and **no delivery
guarantee**, so redelivery after a connector fails mid-teardown is inside what
the system claims rather than a defect worth building against.

Reverted: `_hydrated_state`, `serving=`, `_started`, the room-mismatch watermark
guard, both `_stop_processor` changes, and the loader's sequence-element check
(which guards a field nothing writes yet). With them go eight tests, ten fixture
edits, and the AST guard whose only reason to exist was `_started`.

Kept, and all read-only bar one: the filter and its parsing, the state
derivation, `merged_view`, the row, the CLI table and flags, `failed`, and
`_is_resident` — which stays because a `reset` in flight otherwise reads
`failed`, and that is the view being wrong rather than another layer being
wrong. The one write kept is recording the resolved room's name at start, so the
ROOM column is not a second copy of ROOM ID.

The design gains the transition-in-flight rule it was missing, and #118 / #119
carry what was found and deliberately not fixed.

Authored-By: Hammer Mei (铁锤老妹🔨)
@HammerMei

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: acbe3084e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CHANGELOG.md Outdated
Comment on lines +42 to +46
- **`pause`, `resume` and `reset` act on the same records `list` shows.** They read
only the records this process had loaded, so a watcher whose agent was unavailable
at boot — visible in `list`, with its session id — was mutated by writing a *blank*
record over the persisted one: session id gone, watermark reset, messages
redelivered on the next start. The verbs now pull the persisted record in first.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore hydration before claiming verbs preserve disk records

When a watcher is skipped because its agent is unavailable, its persisted record is visible through merged_view() but absent from WatcherLifecycle._states; pause_watcher() therefore fabricates a blank record and resume_watcher() starts with state=None, after which the in-memory record wins during save. Thus the behavior described here as fixed is still present: following this guidance can silently discard the session ID and watermark, causing lost continuity and message redelivery. Either hydrate the persisted record before these verbs act or remove this release-note guarantee.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is the clearest kind of finding: the entry described behaviour this branch had for four commits and then reverted, on the one document a released user reads.

The revert is deliberate — the increment is a read-only view over persisted records, and making the verbs agree with list requires writes that do not belong in it. The underlying defect is real, pre-existing, and now tracked as #118, where it is also the one of three worth fixing.

The entry now says that instead: list can show a record the verbs cannot act on, this change makes it visible rather than introducing it, and #118 carries it.

Worth recording the class, since it is not one I had a check for: a revert has to sweep the prose it wrote, and that prose is not in the diff being reverted. Nothing points from _hydrated_state back to the paragraph describing it. Fixed in b8630aa.

CLAUDE.md listed three legitimate reasons to decline a review finding, and all
three were about severity, likelihood or cost. None of them was scope — so a
finding that was true and cheap to fix left no written basis for declining it,
and the honest answer to "why did you fix that?" was "because it was true".

Three additions, from an increment that spent four review rounds on defects it
did not own:

* **Two questions, in order, before fixing anything**: is it true (traced to an
  observable outcome, not to "this can return None"), and is it *this change's*
  job. The second is the one that gets skipped, because a true and cheap finding
  feels like it has already earned its way in.
* **"Outside this increment's scope" as the first legitimate reason to decline**,
  and the note that this is the most common correct answer for a defect that
  existed before the change and will exist after it. Plus a severity note:
  losing messages is not automatically severe — a server that has burned down
  loses messages and that is fine — so check `docs/requirements.md`, which
  promises no delivery guarantee, before calling redelivery a defect.
* **A stop-rule for the convergence signal.** Findings landing in the previous
  round's fix: once is noise, twice consecutively is the signal, and the
  response is to re-read the increment's definition rather than write another
  patch. That signal fired at round three here and was recorded rather than
  acted on, which cost two more rounds.

The mechanical half is in the PR description: the increment's one-line
definition now sits at the top, verbatim, where it is in front of both author
and reviewer.

Authored-By: Hammer Mei (铁锤老妹🔨)
Codex, round five, correctly: the entry said the operator verbs now act on the
records `list` shows. They did, for four commits, and then the change that
restored this increment to a read-only view took that out — leaving the
CHANGELOG describing behaviour the branch no longer has, on the one document a
released user reads.

Replaced with what is true: `list` can show a record the verbs cannot act on,
that is pre-existing and made *visible* rather than introduced here, and it is
tracked in #118.

Worth naming as a class: a revert has to sweep the prose it wrote, and the
prose is not in the diff being reverted. Nothing in the working tree points from
`_hydrated_state` to the paragraph that described it.

Authored-By: Hammer Mei (铁锤老妹🔨)
@HammerMei
HammerMei merged commit 968b7a1 into feature/dynamic-watcher-implementation Aug 16, 2026
5 checks passed
@HammerMei
HammerMei deleted the impl/list-filter branch August 16, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant