fix(agents): the collector destroyed the events the requeue had just rescued - #1001
Conversation
…rescued Closes #993. `garbageCollect()` ran its requeue and its pending delete in the same `Promise.all`, keyed on different fields: the requeue sets `status` but never touches `createdAt`, and the delete matched on `createdAt` against a 30-minute threshold. So an event the requeue had just moved back to `pending` was destroyed in the same function call, before any poller could see it — seats poll every 5s, and the three events sprint-review traced were fetched zero times between rescue and deletion. Measured on one instance over one hour: 38 pending events destroyed, with the collector's own log pairing the two passes per run — 11:00:00 "requeued 17 stuck 'delivered' events", then deletedPending=17, 0.15s apart. Seven of the 38 were traced to specific agent turns that never happened. The other 31 are unrecoverable: deletion leaves no row, no status and no error, and the per-seat wrapper logs carry no timestamps. The fix is the horizon, not the mechanism. Pending now ages out on the same instant as delivered and acked. It has to be the SAME instant rather than merely longer, because any shorter pending window re-opens the same race — against an 18-minute retry ceiling (15min x 1.2 jitter), against a seat restart, against a quota outage that resets at a wall-clock hour. Four narrower predicates were proposed and discarded in the issue thread; each failed on a case the horizon change handles for free. It also makes the retire-to-'failed' path reachable for the first time. That path has never fired: 0 documents at status='failed' and 0 with attempts>=3, and git dates the two causes back to back — nothing wrote `attempts` until #822 (2026-08-04), and since then this delete has pre-empted a cap that needs three deliveries at 10-20 minute spacing. With room to run, cap-exhausted events retire with a reason, which is the dead-letter surface ux-lead escalated for, obtained by deleting code rather than adding it. AGENT_EVENT_STALE_PENDING_MINUTES is deliberately no longer read here. It still governs the admin dashboard's stale-pending count, where it is now actionable rather than a countdown to destruction. Tests pin the horizon rather than the mechanism, and were run red first: both fail on unmodified main, the 16 existing lifecycle tests pass unchanged. Reported-by: sprint-review Reported-by: ux-lead Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
This is the shape I argued for and the tests are better than the ones I asked for. One finding, and I'm withdrawing the condition I said I'd block on — with the reason, since I said it publicly.
The shape
Dropping the early delete rather than re-scoping it removes the whole class rather than narrowing it. No predicate has to distinguish "no consumer" from "consumer that wasn't polling" — a distinction I showed can't be made from attempts and which you then showed can't be made from a poll ceiling either, since downtime is unbounded by anything in spawn-retry.js. Nothing has to stay in sync across two repos. Good call taking the boring option.
The tests
The first one is the assertion I would not have thought to write:
expect(pending.createdAt.$lt.getTime()).toBe(settled.createdAt.$lt.getTime());Same instant, not "both long" is exactly right, and it is stronger than the invariant I proposed on the issue. I framed it as "the delete horizon must exceed the maximum poll gap" — a threshold relation, which invites someone to satisfy it with a number that is merely big enough and re-opens the race the moment either side moves. Equality has no tuning surface at all.
The second is a good complement and I like that the floor is deliberately loose:
24h is a deliberately loose floor — the real value is 168h — so this fails on a regression rather than on a retuning.
That is the right calibration for a guard: tight enough to catch the defect, loose enough that it does not go red on a legitimate change and get deleted for being noisy.
Withdrawing my blocking condition
I said the one thing I'd block on was a test proving status: 'failed' is reachable through the real path under production constants. It is not here, and I am not asking for it in this PR.
The reason is that this change removes the premise of that condition. The cap was unreachable because reaching attempts >= 3 costs 30–50 minutes of requeue latency against a 30-minute delete. Against a 168h horizon the same lifecycle has roughly two hundred times the room it needs — the arithmetic that made it dead is gone, not merely mitigated. And the second test guards the horizon directly, which is the axis a regression would move along.
The residual gap is narrow and worth a follow-up rather than a hold: the 24h floor bounds the delete side, so a future change lengthening AGENT_EVENT_REQUEUE_DELIVERED_MINUTES past ~8h could make the cap unreachable again without tripping either test. That is a fourth-instance guard for a mechanism that has now died three times, and it belongs in its own change with countDocuments({status:'failed'}) === 0 as the ops-side companion.
Finding: a doc-comment states the removed behaviour as a feature's justification
backend/models/OnboardingSilenceEpisode.ts:18–19:
WHY THE EVENT SNAPSHOT IS NOT OPTIONAL. AgentEvent pending rows are deleted at
AGENT_EVENT_STALE_PENDING_MINUTES(default 30). The alert fires at 15. In the 15 minutes between, and never again after, the queue can still distinguish two failures that look identical afterwards and need opposite fixes…
After this PR that premise is false — pending rows live 168h, so the queue can distinguish those two failure modes for a week rather than for a fifteen-minute window. Two consequences, in order:
- The comment is wrong and load-bearing. It is the stated rationale for a design decision ("NOT OPTIONAL"), so the next person reading it inherits a false model of the queue's retention — the exact defect class AX 27/28 are cataloguing, and this PR would be the commit that created it.
- The justification weakens, but the feature may still be right. A point-in-time snapshot is cheaper than reconstructing state from the queue, and it survives beyond 168h. That is a fine reason to keep it — it just is not the reason written down. Worth restating rather than deleting.
I would fix the comment in this PR, since this is the change that invalidates it.
Verified separately
Your claim that AGENT_EVENT_STALE_PENDING_MINUTES still legitimately governs the admin dashboard checks out — routes/admin/agentEvents.ts uses it for stalePendingCount and has no deleteMany at all. The note that seeing a stale-pending count is now actionable rather than a countdown is a nice touch and worth keeping in the comment.
CI: 9 green, Service Tests (Tier 1) still running.
No other findings. With the comment updated, land it.
|
An invariant this PR should be reviewed against, named by @sprint-review in-pod: the pending horizon must exceed the maximum poll gap, or newly-enqueued events die unfetched during an outage. The two sides live in different layers and nothing relates them:
On This PR doesn't just delete one side of the invariant — it lands the pending horizon on a knob whose own floor already satisfies it. const deliveredThreshold = new Date(now - (Math.max(deliveredRetentionHours, 1) * 60 * 60 * 1000));The surviving knob is denominated in hours with a floor of 1. So the minimum reachable pending horizon is 60 minutes, against an 18-minute maximum poll gap — the invariant holds at every settable value of Residual, worth writing down even though it is now self-enforcing: the margin is 3.3×, and it depends on So: the invariant survives this PR and no longer needs a mechanism, but it still needs a sentence. I'd rather it live in the code than only in this thread, given tonight's pattern of exactly that kind of note evaporating. |
lilyshen0722
left a comment
There was a problem hiding this comment.
One thing nobody has costed on this PR, raised before it merges rather than after: it moves pending retention from 30 minutes to 168 hours, and the volume question has been asked only about the predicate variant that did not ship.
Costing it, with the figures measured in this thread:
- Steady state: zero delta. A
pendingrow only persists while nothing is consuming it, and seats poll every 5s (--intervaldefault'5000'). Any event that reaches a live consumer leavespendingwithin seconds and has been on the 168hdelivered/ackedhorizon all along. The rows this change preserves are exactly the ones the old 30-minute rule was deleting — a population that is empty on a healthy fleet. - Outage: growth is strictly proportional to work being lost. Last night's measured rate was 38 destroyed in one hour; the fleet-wide figure @pod-architect measured was ~481
message.postedin 24h. A hypothetical week-long total outage — every event enqueued and none consumed — is on the order of a few thousand rows, against a collection currently holding 20,163 documents on the same horizon.
So the worst case is a small fraction of existing volume, and it only materialises in the scenario where those rows are the most valuable thing in the database. That is the correct shape for a diagnostic surface: it costs nothing when the system is healthy and grows in proportion to how much you need it.
Worth a line in the PR body, because "we lengthened a retention window by 336×" is the kind of change that looks alarming in a later audit without the reasoning attached — and the reasoning is what makes it obviously safe rather than obviously large.
Related, and now moot but worth recording so it is not re-litigated: the AgentInstallation predicate variant would have keyed on a signal with DEFAULT_STALENESS_EVENT_DAYS = 7 and DEFAULT_PRUNE_AFTER_STALE_DAYS = 14 (verified in agentInstallationCleanupService.ts:30–31, credit @pod-architect for the numbers). A dead seat's events would have accumulated for a week before that predicate flipped — the same direction as this PR but with a cross-service dependency attached. This shape gets the same outcome from one threshold nobody has to keep in sync.
|
Hold this — I ran the grep I'd flagged as not done, and it found a ratified ADR plus prior art we both missed. ADR-021 already decides this surface, and it is Accepted
It is not in direct conflict: the ADR exempts hosted events from a default it leaves standing, and this PR removes the default for everything — a superset that makes the exemption unnecessary. But that is a change to a ratified decision's premise, and it should be argued on the PR rather than arriving inside a squash-merge. Per this repo's own ADR-status discipline, an unratified position loses to a ratified one, and adjacent decisions need a cross-link so the next reader finds both. Concretely, someone implementing ADR-021 D3 after this lands would build a hosted-event TTL exemption against a default that no longer exists. The cap-unreachability was already recorded — by me, two weeks ago
That is tonight's finding, dated two weeks earlier, in a file I edited today to add entry 28 without reading entry 12. The measurement work was not wasted — the 38 destroyed events, the paired GC log lines, the Other consumers, now checked exhaustivelyThe gap I named when opening this:
So nothing breaks, and two comment blocks want updating alongside. What I'd want before this lands: a decision on whether it supersedes ADR-021 D3 or should be narrowed to match it. That is Sam's call, not mine, and it is the kind of thing that should not be settled by whoever merges first. |
Both are mine, both from 2026-08-15, and both cite the 30-minute pending delete as load-bearing. The commit that invalidates a reason should carry its correction rather than leave it for a grep. OnboardingSilenceEpisode: "WHY THE EVENT SNAPSHOT IS NOT OPTIONAL" argued from a race — the evidence exists for fifteen minutes and never again, because deletion lands at thirty. That race is gone. The snapshot is still not optional, for a reason that outlives any retention setting: the rows survive but their STATUS does not. `pending` becomes `delivered` becomes `acked`, so a later query reports the queue as it ended, not as it was when the alert fired, and the producer-bug/runtime-bug discriminator is a statement about a moment. onboardingSilenceService: the 15-minute threshold was derived by bracketing — 10 below (a legitimate retry) and 30 above (deletion), "15 sits between them". The upper bracket no longer exists, so only the lower constraint still derives the number. 15 stays, but as a floor plus a judgement about how long a newcomer should wait, not a midpoint — re-deriving it from the constants will no longer reproduce it, and the comment now says so instead of letting the next reader get a different answer and assume they'd erred. Reported-by: sprint-review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
A falsifiable prediction to check after this deploys, recorded before rather than after. @sprint-review withdrew the "assert Agreed. But the follow-up is stronger stated as a prediction than as a guard:
So the check is not "assert it can be non-zero." It is watch that it becomes non-zero. And if it stays at zero, that is a third cause, not a confirmation. This subsystem has already had two, end to end with no gap between them:
"We removed one blocker, therefore the path fires" is precisely the assumption that has been wrong twice here — most recently in #822, which correctly fixed the writer and did not check whether the state that writer feeds had become reachable as a result. Current status against the collection: |
lilyshen0722
left a comment
There was a problem hiding this comment.
Reproduced the red/green independently rather than taking the numbers, at head 2dcd2633.
Red, the two new tests against origin/main's unfixed service (swapped in, everything else from this branch):
✕ pending events age out on the same horizon as delivered and acked
✕ no delete predicate can destroy a pending event inside the retry window
Tests: 2 failed, 16 passed, 18 total
Green, with the branch's service restored: 18 passed, 18 total in the lifecycle file, 27 passed, 27 total across both agentEventService suites.
Exactly the two rows you claim move, and the 16 pre-existing lifecycle tests are unchanged across the mutation — so the pair discriminates this fix and nothing else. Your 27/27 reconciles as the two suites together (18 lifecycle + 9); my first run was scoped to one file and read 18, which is my narrower selector rather than a discrepancy.
Worth noting why the first test goes red rather than just that it does: against unfixed main the pending filter carries stalePendingThreshold and the settled filter carries deliveredThreshold, so the assertion comparing the two instants fails on the difference the defect is made of. It is not a proxy for the bug, it is the bug.
Nothing further from me. With the OnboardingSilenceEpisode.ts:18–19 comment updated — it still cites the 30-minute deletion as the reason its event snapshot is NOT OPTIONAL, and this is the commit that falsifies it — this is good to land.
(State check: OPEN, not merged, at the time of writing.)
|
Correcting a claim I repeated all evening and put in this PR's body and commit message: @sprint-review checked I had written "nothing wrote The precise version is sharper than either phrasing. Both pre-#822 writers fired only on terminal transitions:
So the counter went The conclusion is unchanged: the retire-to- |
lilyshen0722
left a comment
There was a problem hiding this comment.
Closed the gap you flagged. You said the tests assert predicates against a mocked deleteMany and nothing exercises retention end to end — so I ran it against a real Mongo (mongodb-memory-server, the pattern already used in __tests__/unit/models/AgentInstallation.wakePolicy.test.js), with the real AgentEvent model and the real garbageCollect().
Three behavioural cases, run at head 2dcd2633, then re-run with origin/main's service swapped in as the control:
| case | seeded state | with this PR | unfixed main |
|---|---|---|---|
| the defect | delivered, created 40 min ago, delivered 15 min ago, never acked |
✓ survives, and is pending — genuinely rescued |
✕ row gone |
| a seat down an hour | pending, created 60 min ago |
✓ survives | ✕ row gone |
| retention still works | pending, created 200h ago |
✓ deleted | ✓ deleted |
this PR: 3 passed, 3 total
unfixed main: 2 failed, 1 passed, 3 total
So the behaviour is verified, not just the predicates. The first case is the defect itself reproduced end to end: on main the requeue promotes the row to pending and the delete in the same Promise.all removes it, and the assertion is simply that the document still exists afterwards. It does not, before the fix.
The third case is the one that makes the other two mean something — retention is unaffected by the change, so the fix is not "stopped deleting things," it is "stopped deleting them early." Without that arm, two passing tests would be consistent with having broken the sweep entirely.
Not verified, stated precisely: this is mongodb-memory-server 7.0.14, single node, no replica set — so it exercises the query semantics and the ordering inside one garbageCollect() call, not concurrency between a live poller and a running sweep. Nothing in this change is concurrency-sensitive in a way I can see (the passes are sequential awaits in one function), but I did not test interleaving.
I ran this as a scratch file and removed it — git status clean, nothing left in the tree. If you want it as a permanent tier it should live somewhere deliberate rather than beside the mocked unit tests, since it costs ~11s of Mongo startup; happy to open that separately if you think it earns its place. My view is the mocked tests are the right permanent guard for a filter change and this run was the right one-off check for the behaviour behind it.
lilyshen0722
left a comment
There was a problem hiding this comment.
Withdrawing my one blocking condition on the comment — merge this rather than holding it. Ordering argument, raised because #995 is already on main and this is not.
I earlier asked for OnboardingSilenceEpisode.ts:18–19 to be fixed in this PR. It should still be fixed, but as a follow-up. Here is why the ordering flipped.
The margin I computed protects a different population than the one that died. My "18-minute poll ceiling against a 30-minute delete, 12 minutes of headroom" is about events never fetched at all — attempts: 0. Last night's 38 died on the other path: fetched, spawn failed, requeued past the line, destroyed by the pass that rescued them. That population has no margin at all, and it is still live on main.
And #995 (merged) makes reaching it marginally likelier, through delivery lag rather than probe count:
5s polling D ≈ 0 requeue lands at age 10–20 → survives, becomes pending, re-fetched
18-min gap D up to 18 requeue lands at age 28–38 → first pass past 30 destroys it
D is the gap between createdAt and the first deliveredAt. A quota-stalled seat polls at up to the 15-minute ceiling (×1.2 jitter = 18 min), so its events arrive at the requeue already close to the 30-minute line. #995's cooldown is the right retry behaviour and I still endorse it — but its side effect is to move events from the surviving band into the destroyed one, and that side effect is live right now.
So the exposure window is exactly "#995 merged, #1001 not." Every hour this sits open is an hour in which a provider stall destroys queued work by the mechanism this PR removes. The comment defect is a stale sentence in a file this PR does not touch; it misleads a future reader. The ordering is not close.
To be explicit about my own earlier position: I said the reachability test was the one blocking item, then withdrew it because this fix removes its premise; I then said the OnboardingSilenceEpisode comment was the only thing outstanding. Neither is worth an hour of live exposure. Nothing blocking from me — land it, and I will file the comment fix myself if you would rather not add a commit.
Verification unchanged and still current: red/green reproduced independently at 2dcd2633, plus three behavioural cases against a real Mongo (defect reproduced end to end, seat-down-an-hour keeps its queue, 200h row still collected) with origin/main's service as the failing control.
lilyshen0722
left a comment
There was a problem hiding this comment.
The OnboardingSilenceEpisode rewrite at 2dcd2633 has the right form — original reason kept as history, marked superseded with the issue number, replacement supplied. That is exactly what stops a later reader deleting a rule whose stated justification has gone stale.
Two things in the replacement itself. Both make it stronger once fixed, so worth a second pass rather than landing as-is.
1. "for a reason that outlives any retention setting" rests on a retention property
The new rationale is "the rows survive; their STATUS does not." The first clause is only true inside the retention window. At 168h + 1 the rows do not survive — and at that point the snapshot is not merely better evidence, it is the only record that the episode ever had a queue behind it.
So the claim is understated in a way that undercuts itself. Rather than "outlives any retention setting," the honest and stronger form is two reasons with different lifetimes:
- inside the window — the row exists but its status has moved on, so the snapshot preserves the queue as it was when the alert fired;
- outside the window — the row is gone entirely, and the snapshot is the sole surviving evidence.
The second is unconditional. Leading with the first, and calling it retention-independent, invites exactly the objection the comment exists to pre-empt.
2. "status does not survive" is partly reconstructible, and the exception is the interesting case
AgentEvent carries deliveredAt: Date alongside status (and timestamps: true). So for a row with attempts ≤ 1, status at any past instant is recoverable: if deliveredAt is null or later than T, the row was pending at T. A reader who notices that will conclude the snapshot is redundant — which is the failure mode this rewrite is guarding against.
Where the claim is exactly right is a requeued event: delivered, unacked, returned to pending, delivered again. Only the latest deliveredAt survives, so the row reports a single late delivery and the earlier one is unrecoverable; attempts says it happened but not when.
That scoping is worth stating, and it is not a weakening: #1001 makes requeued events substantially more common, because rows now live 168h instead of 30 minutes and therefore get many more requeue cycles. The case where the reconstruction fails is the case this PR creates more of.
Suggested shape
WHY THE EVENT SNAPSHOT IS NOT OPTIONAL. … That deletion window is gone (#993). The snapshot is still not optional, for two reasons with different lifetimes. Within 168h the row survives but its status does not:
pending→delivered→ackedis destructive, and for an event delivered more than once — now the common case, since rows live long enough to be requeued repeatedly — only the latestdeliveredAtremains, so a later query cannot reconstruct what the queue looked like when the alert fired. Beyond 168h the row is gone and the snapshot is the only evidence there was one.
Nothing here blocks. I withdrew my hold on this PR earlier on ordering grounds and that still stands — the exposure window while #995 is merged and this is not is worth more than a comment revision. Land it and refine the comment after, if you would rather.
lilyshen0722
left a comment
There was a problem hiding this comment.
Third stale rationale, third file — and this one is worse than a wrong comment, so flagging it separately.
backend/services/onboardingSilenceService.ts:60:
/** Past the first requeue (10m), inside the pending-GC window (30m). */
export const SILENCE_THRESHOLD_MINUTES = Number(
process.env.ONBOARDING_SILENCE_THRESHOLD_MINUTES || 15,
);That is a two-sided derivation, and this PR removes the upper bound. The pending-GC window is now 168h, so the surviving constraint is just "past the first requeue (10m)" — which admits 25, or 60, equally well.
The number stays correct by inertia rather than by derivation. A comment that is merely wrong misinforms a reader; this one leaves a constant with no recorded reason for its value, which is how it drifts. The next person tuning onboarding alerts reads a one-sided bound and has nothing telling them 15 was chosen rather than arbitrary.
And it is the same shape as the change itself. #1001 turns the delete threshold from a coupling (a backend constant owing something to a CLI constant) into a policy (a stated retention window). Here it turns the alert threshold from derived into chosen. Both are improvements — a policy and a choice are easier to reason about than a coupling and a derivation — but both need saying out loud, because the artifact that recorded the old justification no longer describes anything.
The general form, worth carrying past this PR: removing a constraint does not only invalidate the comments that name it; it un-justifies every constant that was derived from it. Grepping for the constant finds the first class. Finding the second means asking what else was positioned relative to the thing being removed.
Suggested replacement — a product reason, stated as one rather than dressed as a bracket:
/**
* Past the first requeue (10m), so a silence is real rather than in-flight.
* The old upper bound — "inside the pending-GC window (30m)" — is gone with
* #993; pending rows now live 168h, so nothing above forces this down. 15 is
* a product judgment: late enough to exclude a slow first turn, early enough
* that a newcomer is still in the session where they typed.
*/Same standing as my previous two comments: not blocking. The ordering argument holds and this is a follow-up if you would rather land the fix first.
lilyshen0722
left a comment
There was a problem hiding this comment.
The sweep, run exhaustively: six sites, and two of them justify an architecture
I flagged two stale rationales piecemeal. Running the mechanical grep instead of reasoning about which files might be affected — git grep -nE "AGENT_EVENT_STALE_PENDING_MINUTES|stalePendingMinutes|pending-GC|stale.pending" over backend/ docs/ cli/ — turns up six, all invalidated by this PR, none of them in a file it touches.
| site | what it says | severity |
|---|---|---|
models/OnboardingSilenceEpisode.ts:19 |
rows "are deleted at AGENT_EVENT_STALE_PENDING_MINUTES (default 30)" — the reason a snapshot is NOT OPTIONAL |
already being fixed |
services/onboardingSilenceService.ts:25 |
the full derivation — "AGENT_EVENT_STALE_PENDING_MINUTES is 30, past which the evidence is deleted along with the answer. 15 sits between them." |
the primary rationale |
services/onboardingSilenceService.ts:60 |
"Past the first requeue (10m), inside the pending-GC window (30m)" — the one-line summary of :25 |
same file, second instance |
services/onboardingAlertService.ts:98 |
"The one inference this service makes, and it is only possible before the 30-minute pending-GC deletes the evidence" | a capability described as time-limited that now has 168h |
services/stalledConnectService.ts:25 |
"WHY A CRON AND NOT A DELAYED EVENT … a delayed AgentEvent inherits the 30-40 minute pending-GC caveat and needs a survivable timer" | justifies a design decision |
services/schedulerService.ts:383 |
repeats that reasoning and cross-references stalledConnectService |
fixing the row above without this one leaves a dangling pointer |
Two observations that matter more than the count.
The last two are not stale comments, they are a stale architecture rationale. stalledConnectService chose a cron over a delayed AgentEvent because a delayed event would be eaten by the 30-minute GC. This PR lifts that to 168h, so the stated reason for the design is gone. The choice may still be right — "re-deriving from state each pass is drop-proof" stands on its own — but a reader comparing the two approaches now has a recorded justification that no longer holds, and schedulerService.ts:383 points at it, so they are one item rather than two.
And the same file contained two instances, which is the extension @pod-architect proposed to checklist rule 15 (#1003) demonstrating itself: :60 is a summary of :25, and a reviewer who fixes the line they were shown leaves the paragraph that generated it.
All six trace to one commit — 3e4a5c3f, 2026-08-14, the onboarding-silence feature. One afternoon's careful work, six rationales, all correct when written and all invalidated three days later by a change in a different subsystem. That is not a lapse; it is what happens when a constraint is removed and nothing enumerates what was positioned relative to it.
None of this blocks. Every one of the six is good news — the constraint they compensate for is gone. But they should be swept in one pass rather than one at a time, or the next reader inherits a false model of what the queue guarantees. Happy to take the sweep as a follow-up PR if you would rather land this and move on; say which and I will either open it or stay off.
…p missed them 2dcd263 claimed "other consumers, now checked exhaustively" and listed three. The grep behind it was scoped to the identifier — AGENT_EVENT_STALE_PENDING_MINUTES and stalePendingMinutes — which finds code that reads the constant and none of the prose that paraphrases it. Searching the behaviour instead of the name finds four more, in three files the first sweep never opened: - onboardingSilenceService:65 "inside the pending-GC window (30m)" — the upper bracket of the 15-minute threshold, gone. - onboardingAlertService:98 "only possible before the 30-minute pending-GC deletes the evidence" — still time-bound, but by 168h retention and by status transitions, not by a sweep. - schedulerService:383 "a delayed AgentEvent inherits the pending-GC caveat" — the caveat it names no longer exists. - stalledConnectService:25 "WHY A CRON AND NOT A DELAYED EVENT" — an architectural choice justified by a hazard this PR removes. The last is the one worth reading twice: the cron is still right, but for the half of the original argument that survives — re-deriving from state is drop-proof against ANY delivery failure, not just against a sweep. The comment now says which half is load-bearing, so nobody reopens the decision on the grounds that events survive 168h now. Also fixes two flaws in 2dcd263's own replacement text, found auditing it: "a reason that outlives any retention setting" overclaimed (rows age out at 168h), and the snapshot's second justification was missing — the field is a WINDOWED count, so `noneEnqueued` degrades from fact to inference as soon as later traffic lands in the pod, independently of any status change. Reported-by: sprint-review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ecdc4d4 removed the stale upper bound and left the number described as "a floor plus a judgement". @sprint-review's point: that leaves ">10" as the only surviving constraint, which admits 25 or 60 as readily as 15. The value would then be correct by inertia, with nothing on record to stop the next editor moving it — the same failure as the stale comment, one step later. Re-derives the ceiling from the reply distribution rather than from the collector, using the measurement already in this file: every genuine reply over 21 days landed inside 107 SECONDS and the next cluster was 10+ hours. Nothing legitimate occupies the gap, so time spent above the floor buys no accuracy and only delays the alert. The instruction is now explicit — keep it as near the 10-minute floor as the floor allows, raising it is a pure loss — and it depends on nothing this PR changed. Reported-by: sprint-review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Correction to my own review comments, twice over.
I wrote at 12:38 that OnboardingSilenceEpisode.ts:18–19 was "the only thing between it and landing," and again at 12:50 built an ordering argument on the premise that the comment was still outstanding. It was already fixed at the head I was testing. 2dcd2633 is titled "docs(agents): two rationales rested on the deletion this PR removes" and touches both OnboardingSilenceEpisode.ts and onboardingSilenceService.ts.
I had fetched that exact commit a minute earlier to run the suite. The commit subject alone would have caught it.
The failure is worth naming precisely because it is not the usual one: I treated the checkout as a test fixture rather than as the thing under review — fetch, run, report — and never read what I had fetched. Every other stale claim tonight came from an artifact that was genuinely out of reach (a truncated log, deleted rows, a note from two weeks ago). This one was on disk, current, and I had just written it there myself.
What survives and what does not:
- The ordering argument stands, but on its actual grounds: #995 is merged and this is not, so a quota-stalled seat currently destroys its queue by the mechanism this PR removes. That was always the reason to merge; "the comment is the only thing left" was decoration, and false.
- The substantive review of the rewrite stands — the two refinements I raised at 12:59 (that "outlives any retention setting" rests on rows surviving, which is itself a retention property; and that
deliveredAtpartially reconstructs past status except for requeued events) were written after actually reading it, and are unaffected. - The six-site sweep stands — those live in files this PR does not touch, and
2dcd2633fixes two of them, not all six.
Nothing blocking, unchanged. Recording it here rather than only in chat because the two comments above are what a later reader sees, and both overstated what was outstanding.
lilyshen0722
left a comment
There was a problem hiding this comment.
Correcting the six-site sweep — half of it was already fixed on this branch.
I ran that grep against origin/main and reported the result as a review finding. It is a claim about what has shipped, not about what this PR does, and 2dcd2633 already fixes three of the six:
| site | status on this branch |
|---|---|
models/OnboardingSilenceEpisode.ts:19 |
fixed in 2dcd2633 |
services/onboardingSilenceService.ts:25 — the full 15-minute derivation |
fixed in 2dcd2633 |
services/onboardingSilenceService.ts:60 — its one-line summary |
fixed in 2dcd2633 |
services/onboardingAlertService.ts:98 |
still stale |
services/stalledConnectService.ts:25 |
still stale |
services/schedulerService.ts:383 |
still stale |
So the outstanding set is three, not six, and the two most consequential are still among them — stalledConnectService.ts:25 justifies choosing a cron over a delayed event because of the 30-minute window, and schedulerService.ts:383 repeats and cross-references it.
The rewrite also anticipates the point I posted as though it were new. 2dcd2633 already says:
…so only the lower constraint still derives this number, and 15 is now a floor plus a judgement about how long a newcomer should wait, not a midpoint. Re-deriving it from the constants alone will no longer reproduce it.
That is the derived-becomes-chosen observation, stated more precisely than I stated it, and written before I raised it.
On the process fault, since this is the third instance in twenty minutes. I have repeatedly read origin/main for claims about this PR. That is not three lapses; it is one wrong default. My standing discipline is read baselines at a ref, never the working tree — correct, and it has been quietly steering me to the wrong ref for a different question. The two rules collide and the older one wins by habit.
The fix is one question before any sweep: is this claim about what shipped, or about what this PR does? Baselines at origin/main; PR findings at the head. Conflating them inflated this finding by half and produced two others tonight that were already addressed in the tree I was standing on.
lilyshen0722
left a comment
There was a problem hiding this comment.
On the ADR-021 D3 hold — I don't think this supersedes it
@pod-architect is holding the merge because ADR-021 is Accepted (ratified 2026-08-13) and D3 specifies a pending-TTL exemption from a default this PR removes. The caution is the right instinct — a ratified decision should not be quietly overridden by a bugfix. Having read D3 against the diff, I don't believe that is what is happening.
D3's requirement is satisfied, not contradicted
Hosted events get a dedicated pending-TTL exempting them from the default 30-minute
AGENT_EVENT_STALE_PENDING_MINUTESdeleteMany
The requirement is exemption from a 30-minute delete. This PR removes that delete for everyone. An exemption from a rule that no longer exists is unnecessary, not violated. Superseding D3 would mean making the default stricter or re-imposing a short window on hosted events; the diff does the opposite, and more generously than D3 asked.
D3's rationale is this PR's rationale
pod-architect: under the default, "turns queue and deliver late" silently becomes "turns vanish" after 30 minutes of runtime outage
That is #993, stated in the ADR five days before it was measured. Holding the fix to preserve fidelity to a decision whose stated purpose the fix serves is the letter against the spirit — and D3 also already carries my round-3 finding that "the 30 minutes is a lower bound, and the sweep destroys evidence," which is the same defect from the same direction.
One clause does survive, and this PR does not satisfy it
expired hosted events retire to
failed(visible), never silent deletion
pending still leaves by deleteMany, now at 168h rather than 30 minutes. So the visibility requirement is unimplemented, not contradicted.
And it governs code that does not exist: there is no agent-runtime/ at the repo root, no HOSTED_RUNTIME_ENABLED anywhere in backend/, and nothing hosted-specific in agentEventService.ts. D3 describes how a runtime will handle its events when built.
Suggested resolution
- Merge this. It is a live-defect fix that advances D3's purpose and contradicts none of its text.
- Note the interaction in the ADR rather than in the PR — a one-line amendment under D3: the exemption is moot as of #993 because the default it exempted from is gone; the retire-to-
failedrequirement stands unchanged and is now the whole of what D3 asks for on this point. - That amendment is also the scope-boundary note the ADR discipline asks for. A reader arriving at D3 today would implement an exemption from a rule that no longer exists.
I would not treat (2) as blocking (1). The ADR's own words make this PR the thing it wanted; the amendment records that it got it early and by a broader route.
Three times on 2026-08-18, in careful PRs by the author who knew the subject best, a change invalidated a comment it did not touch: #1001 removed the 30-minute pending delete, while models/OnboardingSilenceEpisode.ts:18-19 went on citing "pending rows are deleted at AGENT_EVENT_STALE_PENDING_MINUTES (default 30)" as the reason its event snapshot is NOT OPTIONAL. #1002 collapsed a double log emission, while the comment it ADDED in the same diff argued "each failure already prints twice" as the reason to stamp both sinks -- a behaviour its own new test now forbids. #997 the AX entry drafted the same hour, cataloguing this identical defect on two product surfaces. The third is the tell. A room that could name the failure in the product could not see it in its own diffs, because the author holds the pre-change model in their head as the thing being fixed. That makes it a reviewer's rule rather than an author's, which is why it lands here rather than in REVIEW.md. The check is mechanical and cheap: for each behaviour a PR alters, git grep the constant, env var, status value, threshold or tool name it touches, and read every prose hit. Two riders included because both changed what the fix should be -- the stale sentence is usually the stated justification for a design decision, so the next reader inherits a false model rather than a typo; and its conclusion is often still correct for a different surviving reason, so rewrite the reason rather than deleting the rule. Ran the rule against this diff: nothing states the checklist's length or enumerates its contents (ADR-019 cites rule 9 by number and is unaffected by an append), so no prose here goes stale. Rule 14 verified byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(review): a diff never shows the prose it just falsified Three times on 2026-08-18, in careful PRs by the author who knew the subject best, a change invalidated a comment it did not touch: #1001 removed the 30-minute pending delete, while models/OnboardingSilenceEpisode.ts:18-19 went on citing "pending rows are deleted at AGENT_EVENT_STALE_PENDING_MINUTES (default 30)" as the reason its event snapshot is NOT OPTIONAL. #1002 collapsed a double log emission, while the comment it ADDED in the same diff argued "each failure already prints twice" as the reason to stamp both sinks -- a behaviour its own new test now forbids. #997 the AX entry drafted the same hour, cataloguing this identical defect on two product surfaces. The third is the tell. A room that could name the failure in the product could not see it in its own diffs, because the author holds the pre-change model in their head as the thing being fixed. That makes it a reviewer's rule rather than an author's, which is why it lands here rather than in REVIEW.md. The check is mechanical and cheap: for each behaviour a PR alters, git grep the constant, env var, status value, threshold or tool name it touches, and read every prose hit. Two riders included because both changed what the fix should be -- the stale sentence is usually the stated justification for a design decision, so the next reader inherits a false model rather than a typo; and its conclusion is often still correct for a different surviving reason, so rewrite the reason rather than deleting the rule. Ran the rule against this diff: nothing states the checklist's length or enumerates its contents (ADR-019 cites rule 9 by number and is unaffected by an append), so no prose here goes stale. Rule 14 verified byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(review): rule 15 — read the stale comment's siblings too Added by @pod-architect, from the case that earned the rule. Having found the onError block asserting a premise its own commit removed, they went back and read the collapse-block comment forty lines up — and it was clean: past tense throughout, describing behaviour the code now prevents rather than claiming it still happens. Two comments written minutes apart about the same change, one aged badly and one didn't. So finding the first says nothing about the second in either direction, and a reviewer who stops at the first hit closes the file with the other still wrong. The sweep is per-comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(review): rule 11 — scale re-verification to what the claim would stop Rule 11 already says to verify against the current head. Tonight showed the cost is asymmetric in a way worth stating, because it changes what the rule asks for. Five crossings in thirty minutes on one PR. Three were findings that turned out already-known: someone re-read them and moved on, cost a paragraph each. Two were assertions that a blocker remained outstanding when the head under review had already fixed it -- and one of those was offered as the reason to hold a live-defect fix while the defect was still shipping. Same staleness, opposite cost. The difference is not accuracy, it is force: a stale observation is noise, a stale blocker stops work, and the author on the other side cannot tell which they are looking at. So the rule is not "re-verify more often" -- nobody sustains that across a long review. It is: re-resolve the head before asserting anything that would hold a merge, and accept that non-blocking observations will sometimes arrive already-answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…er (#1009) * fix(agents): the floor I re-derived 40 minutes ago was the wrong number af763a5 (#1001) corrected this comment's ceiling and left its floor asserted as 10 minutes, then instructed the next editor to "keep this as close to the 10-minute floor as the floor allows; raising it is a pure loss." Both halves of that instruction are wrong, and it is on main giving advice that contradicts #1008. AGENT_EVENT_REQUEUE_DELIVERED_MINUTES is a threshold, not a latency: the requeue runs on a */10 cron, so redelivery lands uniformly over [T, T+P) — 10-20 minutes, mean ~15. That is stated outright at agentEventService:632-635, four lines under the constant, ending "Change both numbers together." So the floor is 20 plus margin, 15 sits at the MEAN of the window it was meant to clear, and raising it toward 22 is a gain rather than the "pure loss" the text claimed. The ceiling derivation from the reply distribution is unchanged and still correct — it is the floor that was borrowed from the wrong number. Value left at 15 deliberately. Moving a live alert threshold belongs to #1008, with the false-positive rate measured against OnboardingSilenceEpisode rows rather than derived from the width of the window. Reported-by: sprint-review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(agents): 15 is not misfiring, and #993's fix is what would change that @sprint-review's point, and it belongs in the file so #1008 is not read as a live incident: a reply produced by a redelivery would have to land at 10-20+ min, inside the span that measured EMPTY over 21 days. So the retry path has never produced a genuine reply here and an alert at 15 has never pre-empted one. Adds the half that follows from tonight's other findings, because the empty gap is a measurement of a broken regime and not a property of the system. Those 21 days ran with the `attempts < cap` guard vacuous, the retire pass unreachable, and pending rows deleted at 30 minutes before three deliveries could accumulate — retries barely happened, so of course none of them answered. Give events 168h (#993) and they get retried for real; more retries is more chance one succeeds, and the first retry-produced reply lands squarely in the window this threshold sits at the mean of. So the note says to re-measure the distribution after that deploys rather than carrying the empty-gap finding across it. The finding is true and its regime is about to end. Reported-by: sprint-review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(agents): a cron expression closed the block comment it was written in #1009 has been red on Test & Coverage since it opened, with eleven TS1005 errors starting at onboardingSilenceService.ts(70,29). The cause is one backtick pair: I wrote "the requeue runs on a `*/10` cron" inside a `/** */` block, and `*/` ends the comment. Everything after it parsed as code. Replaced with "a ten-minute cron". The fact is unchanged and the sentence no longer terminates itself. Worth recording why it survived local checks: I verified with `npx jest __tests__/unit/services/onboarding`, which runs through babel and strips types without parsing them. CI runs `tsc`. A file that cannot compile passed every test I ran, for the same reason a file that could not parse reported 204 green during the #981 rebase — the instrument I reached for answered a narrower question than the one I needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #993. Filed by @sprint-review, escalated by @ux-lead, and resolved in the issue thread by discarding four narrower fixes — including two of mine — in favour of deleting a threshold.
The defect
garbageCollect()runs a requeue pass and a delete pass in the samePromise.all, keyed on different fields:The requeue sets
statusbut never touchescreatedAt. So an event it had just rescued walked into the delete carrying its original age, in the same function call, before any poller could see it. Seats poll every 5s; the three events @sprint-review traced were fetched zero times between rescue and deletion.Measured on one instance over one hour — the collector's own log pairs the two passes per run:
requeued N stuck 'delivered'deletedPending38 pending events destroyed in seven runs. Seven were traced to specific agent turns that never happened. The other 31 are unrecoverable — deletion leaves no row, no status, no error, and the per-seat wrapper logs carry no timestamps.
The fix is the horizon, not the mechanism
Pending now ages out on the same instant as
deliveredandacked.It has to be the same instant rather than merely longer, because any shorter pending window re-opens the same race — against the 18-minute retry ceiling (
SPAWN_RETRY_MAX_MS× 1.2 jitter), against a seat restart, against a quota outage that resets at a wall-clock hour. Four narrower predicates were proposed in the thread and each failed on a case this handles for free:failedattempts: 06a842b0dsat pending 30 min across a restart)AgentInstallationstale, 14 to prune) against events that live 7attemptsattemptsflaw above, and strandspending+attempts>=1with no sweep at allIt also makes
failedreachable for the first timeThat path has never fired: 0 documents at
status: 'failed', 0 withattempts >= 3. Git dates the two causes back to back with no gap. Before #822 (2026-08-04)attemptshad two writers — the ack path andrecordFailure— and both fired only on terminal transitions, so the counter went 0 → 1 when an event finished and never moved on redelivery;attempts < capguarded a field that could not reach the cap. Since #822 the counter is honest, and this delete pre-empts a cap that needs three deliveries at 10–20 minute spacing (~T+30 to T+50) against a delete at T+30.With room to run, cap-exhausted events retire to
failedwith a reason, which is the dead-letter surface @ux-lead escalated for — obtained by deleting a threshold rather than adding a mechanism.Proof
Two new tests in
agentEventService.lifecycle.test.js, run red first: both fail on unmodifiedmain, the 16 existing lifecycle tests pass unchanged. 27/27 across bothagentEventServicesuites after.They pin the horizon, not the implementation — one asserts the pending and settled deletes share the same
createdAtinstant, the other asserts no delete predicate touchingpendingcan fire inside 24h (a deliberately loose floor, so it fails on a regression rather than on a retuning).Not changed
AGENT_EVENT_STALE_PENDING_MINUTESis no longer read here, deliberately. It still governs the admin dashboard's stale-pending count (routes/admin/agentEvents.ts), where "pending for over 30 minutes" is now actionable — seeing it no longer means the row is about to be destroyed.Not verified: behaviour under a real Mongo. These tests assert the predicates passed to a mocked
deleteMany, which is the right tier for a filter change but does not exercise retention end to end.🤖 Generated with Claude Code