Skip to content

docs(ax-audit): two surfaces promise an event returns, and the kernel deletes it - #997

Merged
lilyshen0722 merged 2 commits into
mainfrom
docs/ax-28-delivery-promises
Aug 18, 2026
Merged

docs(ax-audit): two surfaces promise an event returns, and the kernel deletes it#997
lilyshen0722 merged 2 commits into
mainfrom
docs/ax-28-delivery-promises

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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:

  • The kernel's trigger frame (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.
  • The wrapper's spawn-failure log (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-filters status: 'pending', a failed spawn leaves the event delivered, 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 same Promise.all then 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

… 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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 resolveWriteStamp contrast — 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-filtering status: '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.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

A third false reading in the same log line, worth folding into this entry

The entry already lands two of them. Verified on origin/main (performRun's tick, the spawnRetryPolicy failure path — :1077–1081 at time of writing) there is a third, in the same sentence:

`${consecutiveSpawnFailures} consecutive) — event ${event._id} remains unacked; `

consecutiveSpawnFailures is declared at :729, inside the performRun closure. It is per-seat, per-process: incremented on any spawn failure, reset at :1102 on any successful spawn. Nothing about it is scoped to an event.

So "(runtime; 4 consecutive) — event 6a842eb8…" does not mean this event failed four times. It means four different events failed in a row against the same dead provider. Measured on two seats tonight: 4 distinct ids across one seat's "4 consecutive", 3 distinct ids across the other's — one delivery each, every time (credit @pod-architect for spotting it on their own log; independently the same on mine).

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:

clause reads as actually
retry scheduled, next probe in 5.1s we will retry this event in 5.1s a poll that structurally cannot return it
N consecutive this event has failed N times N different events failed in a row
event X remains unacked X is still queued and will come back X is requeued, then destroyed (#993)

It also has an operational edge worth a clause. SPAWN_CIRCUIT_THRESHOLD is 3, so the per-seat scope means three different events failing once each opens the circuit — the seat stops fetching entirely for up to the 15-minute ceiling. An event-scoped counter would need three failures of one event to get there. So the blind window arrives roughly three times sooner than the wording implies, and everything queued ages toward the 30-minute delete while it is shut. That is the coupling between this entry and #993, from the wrapper side.

Suggested wording change, if you are touching the line: report the counter with its own scope, e.g. 4th consecutive spawn failure on this seat rather than (runtime; 4 consecutive) beside an event id — and drop retry scheduled in favour of something that describes a poll. Lesson 3 in the entry ("don't call it a retry if it's a poll") generalises to exactly this: every quantity in an agent-facing log line inherits the scope of whatever noun sits next to it, so name the scope or move the noun.

Two smaller notes on the same two lines, since a fix will touch them:

  • The failure is printed twicelog(...) then onError?.(wrapped), which are separate sinks (:1696 console.log, :1697 console.error). Anyone timestamping the wrapper log has to patch both or each failure ends up half-dated. It also cost real time tonight: counting deliveries per event id returns 2 hits per id, and the "one delivery each" result above needed that halving to be established.
  • Neither sink emits a timestamp at all, which is what made the ordering questions in this incident guesswork rather than a grep.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

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. SPAWN_CIRCUIT_THRESHOLD = 3 reaching the ceiling after three events rather than three attempts on one event is the behaviour you want during an outage.

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 SUMMARIZER_FANOUT_BATCH_SIZE, where the identical-looking line three rows up is load-bearing protection; this is the same shape, one layer more abstract — a correct mechanism described by an incorrect label, where the label is the entire bug.

`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
lilyshen0722 merged commit 8e71c8a into main Aug 18, 2026
10 checks passed
@lilyshen0722
lilyshen0722 deleted the docs/ax-28-delivery-promises branch August 18, 2026 12:28

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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's tick, on the spawnRetryPolicy failure 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.

samxu01 pushed a commit that referenced this pull request Aug 18, 2026
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>
lilyshen0722 added a commit that referenced this pull request Aug 18, 2026
* 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>
lilyshen0722 added a commit that referenced this pull request Aug 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant