Query Praxis by canonical entity ref and back off enrichment retries #83

Closed
claude wants to merge 1 commits from overnight/eco-entity-refs into overnight/eco-degraded-suite
Contributor

Maven's half of end-to-end entity propagation.

What changed

  • cmd/mavend/ecosystem_acts.go: new entityAttentionCapability (verbs entity_attention, что с, как дела у, статус). It resolves the subject through Nexus, then calls the already-existing praxisClient.ListAttentionForEntity so the canonical entity_id reaches Praxis as a query scope instead of being resolved and discarded. Spoken items are surfaced, not acknowledged. Facts already tagged with the same entity_id (store.FactsByEntity) are folded into the same answer.
  • Distinct replies for resolved / ambiguous / unknown / degraded / no-Nexus. Praxis is never queried without a scope.
  • cmd/mavend/factenrichment.go: per-fact exponential backoff (1 min doubling, capped at 1 h) after a transient resolve failure, plus an enrichmentStatus report (pending, in-backoff, worst attempt count). Retry state is in memory on purpose — a restart legitimately retries everything. No fact is ever abandoned.
  • cmd/mavend/entityrefs_test.go: fixtures for resolved, ambiguous, missing and delayed enrichment, plus backoff hold/release and cap.

Out of scope (other repos)
Praxis observation intake still accepts entity_refs and drops them, and Praxis has no active deferred-enrichment worker. Both are Praxis-side; recorded on the Vikunja task rather than reached into from here.

Verified
make build and make test both exit 0.

Vikunja #272

Maven's half of end-to-end entity propagation. **What changed** - `cmd/mavend/ecosystem_acts.go`: new `entityAttentionCapability` (verbs `entity_attention`, `что с`, `как дела у`, `статус`). It resolves the subject through Nexus, then calls the already-existing `praxisClient.ListAttentionForEntity` so the canonical `entity_id` reaches Praxis as a query scope instead of being resolved and discarded. Spoken items are surfaced, not acknowledged. Facts already tagged with the same `entity_id` (`store.FactsByEntity`) are folded into the same answer. - Distinct replies for resolved / ambiguous / unknown / degraded / no-Nexus. Praxis is never queried without a scope. - `cmd/mavend/factenrichment.go`: per-fact exponential backoff (1 min doubling, capped at 1 h) after a transient resolve failure, plus an `enrichmentStatus` report (pending, in-backoff, worst attempt count). Retry state is in memory on purpose — a restart legitimately retries everything. No fact is ever abandoned. - `cmd/mavend/entityrefs_test.go`: fixtures for resolved, ambiguous, missing and delayed enrichment, plus backoff hold/release and cap. **Out of scope (other repos)** Praxis observation intake still accepts `entity_refs` and drops them, and Praxis has no active deferred-enrichment worker. Both are Praxis-side; recorded on the Vikunja task rather than reached into from here. **Verified** `make build` and `make test` both exit 0. Vikunja #272
claude added 1 commit 2026-08-01 04:52:44 +02:00
Add an entity-scoped attention capability: the subject is resolved to a
canonical Nexus entity_id, the id travels to Praxis as a query scope
instead of being dropped after resolution, and Maven's own facts already
tagged with the same id join the answer. Ambiguous, unknown, degraded and
no-Nexus cases each get a distinct reply and never a scoped query without
a scope.

Give the fact-enrichment worker per-fact exponential backoff capped at an
hour and a status report of pending/in-backoff/worst-attempt counts, so a
long Nexus outage shows as a visible backlog rather than facts that
silently never got tagged. Nothing is ever given up on.
claude reviewed 2026-08-01 11:32:33 +02:00
claude left a comment
Author
Contributor

The resolved entity_id travels to Praxis as a query scope. It is not resolved and then thrown away for a client-side filter. That is what makes the ref canonical. The refusals around it are right too. No Nexus means no scoped query at all, rather than an unscoped list relabelled as an answer about one entity. Ambiguity stops before Praxis is touched. TestEntityAttention_MissingAndDegradedAreDistinct pins the distinction that matters most, between "no such thing" and "I could not ask". Keeping the retry state in memory is the correct call. A restart legitimately retries everything, and the comment says so.

1. A tick where the batch head is in backoff does no work at all. PendingFactResolutions is ORDER BY id ASC LIMIT 20 and knows nothing about backoff. due() filters afterwards. So the 20 oldest pending facts occupy the batch whether or not they are eligible. Walk it: Nexus is down for one tick, all 20 fail, each gets nextTry = now + 1m. Facts 21 through 500 are due and untouched. The next tick pulls the same 20 ids, skips all 20, and returns having resolved nothing. The backoff was meant to spare a struggling Nexus. It also stalls a healthy one behind facts that are not eligible yet. The sharp version is a poison subject, one that makes Nexus error every time. It is never given up on, by design. So it holds a batch slot forever, doing nothing for an hour at a time. Twenty of those and enrichment stops for good. No error, no log line, because failed is zero on a tick where everything was skipped. Either exclude backed-off ids in SQL, or keep pulling pages until batch due facts are collected.

2. status has no caller outside the test. The comment says degradation is reported and never hidden. It says a Nexus down all day must be visible as a backlog. Nothing reads enrichmentStatus. Not the dashboard, not /trace, not a log line, not the digest. tick logs only when failed > 0, and per finding 1 that counter is zero in exactly the stalled state worth seeing. As written the backlog is visible to TestEnrichmentBackoff_HoldsAndReleases and to nobody else. One log.Printf per tick when Pending > 0, or a row on /trace, would make the comment true.

3. InBackoff cannot exceed the batch size, and Pending counts to 1000. w.skipped is set from the last tick, which iterated at most 20 facts. st.Pending comes from a fresh query with limit 1000. So a 500-fact backlog with every fact backed off reports Pending: 500, InBackoff: 20. MaxAttempts has the same ceiling: it only sees facts that reached the head of a batch. The three numbers are not on the same denominator, and the field names do not say so. skipped is also stale between ticks, and interval in deploy is not seconds, so the reported figure describes a window that has already passed.

4. The scoped query fails open against a Praxis that does not know entity_id. ListAttentionForEntity appends &entity_id=... and trusts the response. An older praxisd that ignores unknown query parameters returns the full unscoped list, and Maven reads it back as по «Muzick indexer»: disk almost full. That is the exact outcome TestEntityAttention_WithoutNexusSaysSo exists to prevent, arriving through a different door. The fake cannot catch it, because newFakePraxis serves the same fixture regardless of the query string. PR 82 built fixtureNexusResolvedFlat for precisely this class of "the other side is older than you think". The cheap guard is client-side. Drop any returned item whose own entity_id is present and does not match. Refuse the answer when none of them carry one.

Smaller:

  • A store write failure in resolveOne returns false after the delete(w.attempt, ...) has already run, so ResolveFactEntity failing gives no backoff at all. That path retries every tick with no ceiling. It is the one failure mode where the counter says something is wrong and the pacing does not apply.
  • aliases() lists "что с", "как дела у" and "статус", and dispatch is alias == dec.Slots.Fn. Fn carries a function slot from the act grammar, not free Russian. Those three are unreachable as written. Either the router emits them or they should go. An alias list that never matches claims coverage that is not there.
  • attempt and nextTry are only cleaned on a successful resolve. A fact can leave the pending set another way, voided or resolved by a later write. Its entries then stay for the lifetime of the process.
  • recordPraxisTrace(ctx, "entity_attention", ...) inherits the Kind: "system" problem from PR 82, so the new trace is discarded like the others until 84 lands.
  • localFactsForEntity takes three facts and drops the rest with no marker. Reading three of eleven as "я помню: ..." presents a truncation as the whole of what she knows.
The resolved `entity_id` travels to Praxis as a query scope. It is not resolved and then thrown away for a client-side filter. That is what makes the ref canonical. The refusals around it are right too. No Nexus means no scoped query at all, rather than an unscoped list relabelled as an answer about one entity. Ambiguity stops before Praxis is touched. `TestEntityAttention_MissingAndDegradedAreDistinct` pins the distinction that matters most, between "no such thing" and "I could not ask". Keeping the retry state in memory is the correct call. A restart legitimately retries everything, and the comment says so. **1. A tick where the batch head is in backoff does no work at all.** `PendingFactResolutions` is `ORDER BY id ASC LIMIT 20` and knows nothing about backoff. `due()` filters afterwards. So the 20 oldest pending facts occupy the batch whether or not they are eligible. Walk it: Nexus is down for one tick, all 20 fail, each gets `nextTry = now + 1m`. Facts 21 through 500 are due and untouched. The next tick pulls the same 20 ids, skips all 20, and returns having resolved nothing. The backoff was meant to spare a struggling Nexus. It also stalls a healthy one behind facts that are not eligible yet. The sharp version is a poison subject, one that makes Nexus error every time. It is never given up on, by design. So it holds a batch slot forever, doing nothing for an hour at a time. Twenty of those and enrichment stops for good. No error, no log line, because `failed` is zero on a tick where everything was skipped. Either exclude backed-off ids in SQL, or keep pulling pages until `batch` due facts are collected. **2. `status` has no caller outside the test.** The comment says degradation is reported and never hidden. It says a Nexus down all day must be visible as a backlog. Nothing reads `enrichmentStatus`. Not the dashboard, not `/trace`, not a log line, not the digest. `tick` logs only when `failed > 0`, and per finding 1 that counter is zero in exactly the stalled state worth seeing. As written the backlog is visible to `TestEnrichmentBackoff_HoldsAndReleases` and to nobody else. One `log.Printf` per tick when `Pending > 0`, or a row on `/trace`, would make the comment true. **3. `InBackoff` cannot exceed the batch size, and `Pending` counts to 1000.** `w.skipped` is set from the last tick, which iterated at most 20 facts. `st.Pending` comes from a fresh query with limit 1000. So a 500-fact backlog with every fact backed off reports `Pending: 500, InBackoff: 20`. `MaxAttempts` has the same ceiling: it only sees facts that reached the head of a batch. The three numbers are not on the same denominator, and the field names do not say so. `skipped` is also stale between ticks, and `interval` in deploy is not seconds, so the reported figure describes a window that has already passed. **4. The scoped query fails open against a Praxis that does not know `entity_id`.** `ListAttentionForEntity` appends `&entity_id=...` and trusts the response. An older praxisd that ignores unknown query parameters returns the full unscoped list, and Maven reads it back as `по «Muzick indexer»: disk almost full`. That is the exact outcome `TestEntityAttention_WithoutNexusSaysSo` exists to prevent, arriving through a different door. The fake cannot catch it, because `newFakePraxis` serves the same fixture regardless of the query string. PR 82 built `fixtureNexusResolvedFlat` for precisely this class of "the other side is older than you think". The cheap guard is client-side. Drop any returned item whose own `entity_id` is present and does not match. Refuse the answer when none of them carry one. Smaller: - A store write failure in `resolveOne` returns `false` after the `delete(w.attempt, ...)` has already run, so `ResolveFactEntity` failing gives no backoff at all. That path retries every tick with no ceiling. It is the one failure mode where the counter says something is wrong and the pacing does not apply. - `aliases()` lists `"что с"`, `"как дела у"` and `"статус"`, and dispatch is `alias == dec.Slots.Fn`. `Fn` carries a function slot from the act grammar, not free Russian. Those three are unreachable as written. Either the router emits them or they should go. An alias list that never matches claims coverage that is not there. - `attempt` and `nextTry` are only cleaned on a successful resolve. A fact can leave the pending set another way, voided or resolved by a later write. Its entries then stay for the lifetime of the process. - `recordPraxisTrace(ctx, "entity_attention", ...)` inherits the `Kind: "system"` problem from PR 82, so the new trace is discarded like the others until 84 lands. - `localFactsForEntity` takes three facts and drops the rest with no marker. Reading three of eleven as "я помню: ..." presents a truncation as the whole of what she knows.
kami closed this pull request 2026-08-01 14:52:09 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#83