Repository navigation
feat(list): report state records with a state filter, and name the failed state - #116
Conversation
`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 (铁锤老妹🔨)
|
@codex review |
There was a problem hiding this comment.
💡 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".
| | 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 | |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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).
| if isinstance(value, list): | ||
| value = ", ".join(value) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 forparticipants, so a second sequence field cannot be added without its elements being checked. A test enumerates every sequence-typed field onWatcherStateand 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.
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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_countis 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
getto recreate an idle watcher transparently precisely because scheduled work has no other route back. It also cannot occur today — nothing writesdropped_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
…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 (铁锤老妹🔨)
|
@codex review |
There was a problem hiding this comment.
💡 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".
| > | `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. | |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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_pausedcheck (gateway/core/scheduler.py:361) is safe, becausesync_watchershydrates a paused record atwatcher_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 (铁锤老妹🔨)
|
@codex review |
There was a problem hiding this comment.
💡 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".
| 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)) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| > | `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`. | |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| if names is None: | ||
| return StateFilter.OPERABLE | ||
| result = StateFilter(0) | ||
| for name in names: |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 (铁锤老妹🔨)
|
@codex review |
There was a problem hiding this comment.
💡 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".
| backend_identity=identity, | ||
| ) | ||
| self._states[wc.name] = ws | ||
| self._started.add(wc.name) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| (plain `list` hides idle watchers). No row means no state record: the | ||
| watcher never started, and the reason is in the startup log. |
There was a problem hiding this comment.
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 (铁锤老妹🔨)
|
@codex review |
There was a problem hiding this comment.
💡 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".
| - **`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. |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 (铁锤老妹🔨)
968b7a1
into
feature/dynamic-watcher-implementation
What this changes
listenumeratedwatcher_configsand derivedactivefrom whether a liveprocessor happened to exist. Under rule-derived watchers neither source
survives: there is no static set of watchers in
config.yaml, and idle andpaused rooms — the ones an operator can still act on — have no processor.
So
listnow reads state records (design §2.8) with a composable statefilter, and the lifecycle gains a fifth state,
failed, for the thing thathad 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 derivedA 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
activetells an operator the opposite of whathappened.
says what is;
failedis the two disagreeing. A stored flag needs a writerand a clearer, and any path that forgets the clearer leaves a record
permanently lying about itself.
dropped_atis checked before residency,because an idle record is supposed to have no processor.
pauseandresetremove theprocessor first and settle the record last, and
resetholds that state for asession, a history fetch and a full model turn. Reporting
failedthere wouldhave the recovery verb accuse itself while working.
future attempts converts a problem the operator can fix into one the system
has quietly decided to live with.
pauseis how an operator says "stop tryingthis one".
wrong.
Also
The merge is stated once.
StateStore.savealready computed "disk overlaidby memory"; that is
merged_viewnow, 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-allindistinguishable.
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".
statusasksfor 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:
listshowsmore records → the operator verbs disagree with it → hydrate the record so they
agree (a write) → hydrated records can carry a stale
room_id→ gate thesave's cursor poll → that empties the set at shutdown → add
_startedtorestore 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:
_startedexisted to restore is inert.unsubscribe_roomdropsthe connector's room entry before
save_state()runs. With a connector thatmodels 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.
room_idcase cannot arise under the design. §2.4: a watcher isbound to
(connector, room_id), and editing the rule that created it does notrebind 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 gracefulhandling 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.mdgains the rule.Verification
make lintclean; unit + integration 3426 passed.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.request["states"]becoming aStateFilteris the only join between the CLI and the reader, and both halveswere 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.
ScriptConnectorreturns the same string for a room's id and name; a watcherconfigured with the default agent could not distinguish a config lookup from
a global fallback; and a record in
_stateswith no processor is a state nostop path produces.