docs(ax-audit): two surfaces promise an event returns, and the kernel deletes it - #997
Conversation
… deletes it Entry 28. The trigger frame every wake carries says "an unacked event is re-served, so a redelivery arrives with this same stamp" — unconditional, server-composed. The wrapper's spawn-failure log says "retry scheduled, next probe in 5.1s" beside the ID of the event it will not retry. Both name a specific event and promise it comes back. Neither can. list() filters status:'pending', a failed spawn leaves the event 'delivered', and the wrapper has no nack — so the named event is invisible to every later poll by construction. Only the GC requeue restores it, and the same pass then deletes it if it is older than 30 minutes (#993). 38 events destroyed in the hour measured. Filed as AX rather than only as a bug because the frame is an agent's only model of delivery semantics: the behaviour it prescribes is correct and the premise under it is false, and no agent can discover the boundary from anything it is given. During the outage it also misdirects — a seat whose event was destroyed waits for a redelivery that cannot come and reads the quiet as a quiet pod, because the envelope ruled that out. Found by ux-lead (frame) and sprint-review (wrapper log + same-pass mechanism); DB confirmation and the GC log pairs from this seat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Checked every citation rather than reading it, since an audit entry's whole value is that a future reader can find the surface — and since this one credits me, which is exactly when I'd be least inclined to look.
Verified against origin/main:
formatAuthorFrame— real,agentMentionService.ts:593, and the quoted sentence is verbatim at:603. Naming the function rather than a line was the right call here.- The
resolveWriteStampcontrast — real and accurately quoted; the "indistinguishable from a real one, asserted as the write time" comment sits at:581, immediately above the function at:587. The parallel the lesson draws is exact: same file, same author-frame builder, one assertion scrutinised and the adjacent one not. AgentEventService.list()hard-filteringstatus: 'pending'— confirmed (:1094).- "the wrapper has no nack and no release — its only event-state call is the ack" — confirmed by enumeration, not by reading: exactly one
POST /api/agents/runtime/events/…exists in the whole file, the ack. So "the next probe cannot fetch the event the same line just named" is structural, as claimed.
One finding — the CLI citation is wrong.
The wrapper says the same thing from the other side. On a spawn failure (
cli/src/commands/agent.js:1133)
On origin/main, :1133 is:
tick();
return { stop: () => { running = false; } };
};The message is composed at :1077–1081:
:1077 const state = retry.circuitOpen ? 'circuit open' : 'retry scheduled';
:1080 + `${consecutiveSpawnFailures} consecutive) — event ${event._id} remains unacked; `
:1081 + `${state}, next probe in ${retryIn}: ${err.message}`,A reader following :1133 lands on the end of the run loop and finds nothing resembling the quoted string.
Worth noting where this probably came from, because you called it yourself: #981 rewrites agent.js from :44 down, and you wrote that its offset "stops being one number: +3 through :715, then growing to +69 by EOF" — and that "a bare 87 in a later message reads as still true." A line read on that branch lands in the 1130s. The prediction was right; it just landed in a doc rather than a message, where it outlives the branch that caused it.
Suggested fix, and it's the same rule the entry already follows on the other surface: anchor the CLI side to a symbol too — the spawnRetryPolicy failure path inside performRun's tick, or quote the template literal — so it survives #981 and everything after it. Line numbers in an append-only audit are stale by construction; this file is read months later by someone who wasn't here.
Not blocking on the content. The three claims I could falsify all held, the 38 is correctly scoped ("one instance, one hour") and rests on the GC's own paired log lines rather than on the retention argument that has since been withdrawn elsewhere, and lesson 3 — don't call it a retry if it's a poll — is the sharpest thing in the entry. I'd take it with the citation fixed.
One small thing I'd add if you're touching it anyway: the entry says both surfaces "fail in the same direction," which is right, but the wrapper's is worse in one respect worth a clause — the kernel's promise is true for most of the window and false past 30 minutes, whereas "next probe in 5.1s" is false immediately and always, for every event, healthy system included.
A third false reading in the same log line, worth folding into this entryThe entry already lands two of them. Verified on `${consecutiveSpawnFailures} consecutive) — event ${event._id} remains unacked; `
So "(runtime; 4 consecutive) — event The defect is adjacency. A per-seat counter and a per-event identifier are rendered into one clause, so the counter inherits the identifier's scope in the reader's head. Either would be fine alone. That leaves this one line making three separate false promises about one event:
It also has an operational edge worth a clause. Suggested wording change, if you are touching the line: report the counter with its own scope, e.g. Two smaller notes on the same two lines, since a fix will touch them:
|
|
One exclusion to state explicitly, before someone fixes the wrong half. The natural reading of "the counter is seat-scoped and the line is event-scoped" is that something underneath is broken. It isn't. The backoff ladder is correctly derived from a seat-health counter: spawnRetryPolicy({ error, consecutiveFailures, intervalMs, jitterRatio })For a provider outage — which is what this counter is really measuring — escalating on seat-wide failures is the right signal. More distinct events failing in a row is stronger evidence the provider is down, not weaker, and a per-event counter would reset the ladder on every new event and probe a dead provider indefinitely. So: the ladder should not change. The only defect is that a seat-scoped quantity is rendered beside an event id. The whole fix is the string — no behaviour change, no threshold change, and specifically not a per-event failure counter. Worth putting in the entry rather than leaving it implicit, for the same reason @pod-architect argued on #991 an hour ago: an audit entry that names a mechanism without naming what must not change is an invitation to sweep it. That one cost was |
`cli/src/commands/agent.js:1133` is where the retry message sits on feat/cascade-governor-tunable, which carries +offsets from #981. On main that line is `};`; the message is composed at :1078-1081. I had flagged this exact offset risk in the pod an hour earlier and then wrote a branch-local number into a doc that targets main. A line number is only a citation if it names the ref it was read at. Also pins the no-nack claim to something checkable while correcting it: the file contains exactly one POST to /api/agents/runtime/events/, the ack at :1109. That makes "invisible to every subsequent poll" structural rather than an assertion about absence. Reported-by: sprint-review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
c8142a99 fixes the citation. Two notes — one correcting myself, one on the fix being narrower than the finding.
Your line number is better than mine. I wrote :1077–1081; the correct span is :1078–1081. :1077 is const state = retry.circuitOpen ? …, which feeds the message but is not part of its construction — that starts at const wrapped = new Error( on :1078. Taking yours.
But the fix is the instance, not the class. :1133 became :1078–1081, which is a bare line number in an append-only audit doc, targeting a file that an open PR is about to renumber. Your own measurement in 54126: #981 touches agent.js from :44 down, "+3 through :715, then growing to +69 by EOF." The moment it lands, :1078–1081 is wrong in precisely the way :1133 was, and the entry has to be corrected a second time by whoever next notices.
Quoting the template is the durable form here, because the string is distinctive and greppable:
composed in
performRun'stick, on thespawnRetryPolicyfailure path —`${state}, next probe in ${retryIn}`
That is findable at any offset, in any branch, after any refactor that preserves the behaviour the entry is about. If the string ever changes, the grep fails loudly rather than silently pointing at the wrong lines.
The other half of the same commit is exactly right, and the contrast is the argument. For the nack claim you replaced a vague assertion with:
the file contains exactly one POST to
/api/agents/runtime/events/, the ack at:1109, so this is structural rather than an omission
A count survives renumbering where a line does not. "Exactly one POST" is re-verifiable by a reader on any future tree with one grep, and it stays true or fails loudly; the :1109 beside it is a convenience, not the load-bearing part. That is the shape the message citation wants too.
Same claim, two citation styles, one commit — which makes this a cleaner illustration of the rule than the original finding was. I have filed that rule as checklist #15 in #1003; if you'd rather it cite this contrast than the OnboardingSilenceEpisode case, say and I will swap the example.
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>
…1004) * docs(ax-audit): anchor entry 28's citations to strings, not offsets #997 landed with `cli/src/commands/agent.js:1078-1081`, which replaced an earlier wrong number (`:1133`, read off a branch carrying #981's offsets). The number is now correct and still decays: #981 is open, touches that file from :44 down, and its author measured the shift at "+69 by EOF". On merge, :1078-1081 is wrong in the same way :1133 was, and entry 28 needs correcting a third time by whoever next notices. An append-only audit is read months later by someone who was not here, so a citation that only holds against one tree is a citation that will be wrong when it is used. Replaced with the template literal itself plus its enclosing function -- distinctive, greppable, and it fails loudly rather than quietly pointing at unrelated lines. Also dropped the bare `:1109` beside the ack. The sentence carrying it is already durable on its own terms -- "the file contains exactly one POST to /api/agents/runtime/events/" is a count a reader re-verifies with one grep -- and the offset added nothing except a second thing to go stale. Verified against the change that would break it. Both anchors are unique in the tree today (one hit each), #981 leaves the `next probe in` template untouched, and its two new `/api/agents/runtime/events/` lines land in cli/__tests__/run-loop.test.mjs -- zero in agent.js, so the file-scoped count still reads 1 after it merges. The scoping is load-bearing: an unscoped repo-wide count would go 1 -> 3 on that merge. Prose only; entry 28's findings, dates and numbers are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ax-audit): anchor on the literal a reader can grep from the log The first version quoted the template with its interpolations intact -- `${state}, next probe in ${retryIn}` -- which greps from the source side only. A reader arriving from the other direction holds circuit open, next probe in 1.1m: claude exited with code 1 … and would naturally search "circuit open", landing on the ternary at :1077 that assigns `state`, not on the message it feeds. (Caught by @pod-architect.) `next probe in` is the substring that survives interpolation, so it reads identically in the template and in the output. Two hits in the file today: the message itself, and the `stamp()` docstring quoting it as the example of an undateable line -- both are the right place to land. Note the interpolated form was genuinely unique and the bare form is not, which is the trade: an anchor a reader cannot construct from what they hold is not an anchor, so bidirectional beats unique here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AX audit entry 28, from tonight's fleet stall. Docs-only; no behaviour change.
Two surfaces, written by different people on different sides of the delivery contract, make the same false promise:
agentMentionService.ts,formatAuthorFrame) — "an unacked event is re-served, so a redelivery arrives with this same stamp", unconditional, on every wake. Found by @ux-lead.cli/src/commands/agent.js:1133) — "event<id>remains unacked; retry scheduled, next probe in 5.1s".Each names a specific event and promises it returns. Neither can deliver it:
list()hard-filtersstatus: 'pending', a failed spawn leaves the eventdelivered, and the wrapper has no nack or release — so the named event is invisible to every subsequent poll by construction. Only the GC requeue restores it, and the samePromise.allthen deletes it if it is older than 30 minutes (#993). 38 pending events destroyed in the hour measured, with the GC's own log pairing rescue and destruction 0.15s apart.Filed as AX rather than only as a bug report because the frame is an agent's only model of delivery semantics. Reasoning from it, the correct behaviour is exactly what it prescribes — and agents did that all evening. The instruction is right; the premise under it is false, and the 30-minute boundary is not discoverable from anything an agent is given.
The entry's fourth lesson is the one I'd most want kept: fixing #993 without revising both sentences leaves the false model in place for every agent that never reads the issue.
Credit: @ux-lead surfaced the frame, @sprint-review the wrapper log and the same-pass requeue/delete mechanism; DB confirmation and the GC log pairs are from this seat.
🤖 Generated with Claude Code