…692)
`seedPromises` memoized the per-session seed promise with no removal on
rejection, and `projectExchange` awaits it. `scanCommittedMessageIds`
walks `partitions` OUTSIDE the try/catch that guards the
`discoverCachePartitions` call, so a storage resolving a truthy
non-iterable threw out of a function documented never to throw: the seed
rejected, `source.js` caught it and dropped the row, and every later
exchange for that session short-circuited onto the poisoned memo and was
dropped with no warn at all.
Enforce the "a seeding miss never costs a row" guarantee where the whole
seed path passes through, instead of resting on a contract each leaf
asserts about itself: absorb the rejection at the memo, warn with
`error_kind: 'seed_rejected'`, and drop the memo so the next exchange
retries and re-warns rather than inheriting a verdict no scan produced.
Correct the false "NEVER throws" JSDoc to say where the guarantee now
lives, and tag the existing discover-failure warn `discover_failed` so
every line in this family names its kind.
Co-Authored-By: Claude <noreply@anthropic.com>
Addresses item A2 of #692 (the actionable, pre-existing defect on master). Item A1 is covered at the bottom.
Root cause
Two lines that only bite together, both pre-existing on master:
scanCommittedMessageIdsputs only theawait storage.discoverCachePartitions(...)call inside its try/catch. Thefor (const part of partitions ?? [])walk over the answer sits outside it, and?? []covers only a nullish answer. A storage resolving a truthy non-iterable (a violation of its own declaredCachePartitionMeta[]return type) therefore throws out of the function, whose JSDoc claimed it "NEVER throws".seedSeenMessagesForSessionmemoizes that promise inseedPromisesand never removes it on rejection.projectExchangeawaits the seed, so the rejection is caught bysource.jsand the row is dropped. Because the memo is never rewritten, every later exchange for that session short-circuits onto the poisoned memo and is dropped with no warn at all. Net symptom: under a storage that violates the contract on every call, the daemon survives but drops every row for every session while going nearly silent (review round 2 on #690 measured five exchanges producing two warn lines and zero rows).The fix, and why this shape
Enforce the guarantee where the whole seed path passes through rather than resting on a "never throws" contract each leaf asserts about itself:
Three decisions worth naming:
aigw.seed_seen_messages_failedwitherror_kind: 'seed_rejected'(matching theaigw.*warn conventions, and theerror_kinddiscrimination introduced for the sibling scan in Committed-session index survives a throwing scan instead of wedging it #690). The existing discover-failure warn in the same function is taggeddiscover_failedso every line in the family names its kind rather than only some of them.seedPromises.get(sessionId) === pendingguard is so a concurrent caller's newer memo is not evicted by this one's failure (same posture as the index'sbuilt === attemptguard).I deliberately did not copy #690's shape here. #690 puts a
.catchon the index's scan promise because that promise has an orphan.thenconsumer whose rejection would be unhandled and kill the daemon. This call path has no orphan: the seed promise is awaited, so its failure mode is a lost row, not a dead process, and the fix belongs at the memo that turns one lost row into all of them.I also chose not to guard the walk inside
scanCommittedMessageIdson top of this. It would fix the one reported input while leaving the absolute contract claim still technically false (e.g.part.partition?.session_idat the top of the loop body is likewise outside the inner try), it would leave the general backstop untested, and it does not help the other way this seed path can reject (the committed-session index it consults first). One mechanism covering the whole path is both smaller and strictly more general.The JSDoc contract
Requirement was not to leave code and doc contradicting each other. The
scanCommittedMessageIdsJSDoc no longer claims "NEVER throws": it now states plainly that only the discover call is guarded, that a storage breaking its own return type throws out of the walk, and that the guarantee callers actually need (seeding never costs a row) is enforced one level up inseedSeenMessagesForSession.seedSeenMessagesForSession's JSDoc gained the matching sentence saying it is where that guarantee lives.Regression test
test/plugins/ai-gateway-message-projector.test.js->seed failure: a storage that breaks its discover contract loses no rows and does not poison the session memoIt drives the real symptom, not just the throw: two exchanges for one session against a storage whose per-session discover answers with a truthy non-iterable every time, asserting per exchange that the row still lands, that
discoverCachePartitionswas re-called for the second exchange (direct evidence no failed memo survived), and that each failing exchange emits its ownseed_rejectedwarn. The index build (discover call 1) is kept well-formed on purpose so the test isolates this defect from #685/#690's index-scan rejection and does not depend on that fix landing.FAIL before (
node --test test/plugins/ai-gateway-message-projector.test.js, fix reverted, test kept):PASS after (same command, fix applied):
Checks
npm test:npm run typecheck: clean (tsc -p tsconfig.json --noEmit, no output).On A1 (item 2 of #692): no code change, by design
A1 is a merge-sequencing note, not a defect in either PR. It records that #689 and #690 conflict on
message_projector.jsand the projector test file, and names the correct composed resolution for whoever merges second (keep #690's.catchnormalizing the scan rejection, then layer #689's completion-stamping chain on top of the caught promise; never take #689'srebuild()wholesale). There is nothing to fix in the tree, so this PR makes no change for it.Note for that merge: this PR also touches
message_projector.js, inseedSeenMessagesForSessionand inscanCommittedMessageIds's JSDoc and discover-failure warn. Those are disjoint from both #689's and #690's regions (rebuild()andscanCommittedSessionIds), so composing is additive; the test file addition is likewise appended well away from #690's insertion point.Fixes #692