fix(13-11): content grid dropped everything except music
The AIUI-03 stale-response guard used ONE counter for every content:request,
so requests for different kinds cancelled each other.
useArchy.ts init fires content('all','own') and library('own') back to back.
Both sequence numbers are assigned synchronously, before either awaits, so the
first request always resolved with a stale number and was silently discarded.
Films, podcasts and this node's own files never reached the grid no matter what
the user did — only music ever arrived. Nothing logged, because discarding is
the guard working as written.
The guard is now keyed by kind+scope. Different kinds populate different grids
and cannot stale each other by definition; only a newer request for the same
grid can, which is what the guard was actually for. The existing out-of-order
test (same kind twice) is untouched and still passes.
This is the same shape as the defect 13-11 already fixed once: machinery built
and unit-tested end to end, while nothing real ever reached the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
23173c024a
commit
aac81503c3
@@ -253,6 +253,53 @@ describe('ContextBroker', () => {
|
||||
expect(freshCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delivers BOTH results when two different kinds are requested together', async () => {
|
||||
// Regression: the guard used one counter for every kind, so requests for
|
||||
// different kinds cancelled each other. useArchy.ts init fires
|
||||
// content('all','own') then library('own') back to back and both sequence
|
||||
// numbers are assigned synchronously before either awaits — so the first
|
||||
// ALWAYS resolved stale and was dropped. Films, podcasts and own files
|
||||
// never reached the grid; only music ever did. Different kinds populate
|
||||
// different grids and cannot stale each other by definition.
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ items: [], tracks: [] } as never)
|
||||
|
||||
const films = callContentRequest('films-req', 'films', 'own')
|
||||
const library = callContentRequest('library-req', 'library', 'own')
|
||||
await Promise.all([films, library])
|
||||
|
||||
const pushed = mockPostMessage.mock.calls
|
||||
.map(([msg]) => msg as { type?: string; id?: string })
|
||||
.filter((m) => m.type === 'content:push')
|
||||
.map((m) => m.id)
|
||||
|
||||
expect(pushed).toContain('films-req')
|
||||
expect(pushed).toContain('library-req')
|
||||
})
|
||||
|
||||
it('still discards a stale response within the same kind and scope', async () => {
|
||||
// The guard's real purpose must survive being made per-key.
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ items: [] } as never)
|
||||
|
||||
await callContentRequest('older', 'films', 'own')
|
||||
await callContentRequest('newer', 'films', 'own')
|
||||
|
||||
// Both completed in order here, so both post; the ordering guarantee is
|
||||
// covered by the out-of-order test above. What this pins is that the key
|
||||
// is kind+scope, so a DIFFERENT scope does not collide with this one.
|
||||
const scoped = callContentRequest('peer-scope', 'films', 'peers')
|
||||
await scoped
|
||||
const pushed = mockPostMessage.mock.calls
|
||||
.map(([msg]) => msg as { type?: string; id?: string })
|
||||
.filter((m) => m.type === 'content:push')
|
||||
.map((m) => m.id)
|
||||
expect(pushed).toContain('peer-scope')
|
||||
})
|
||||
|
||||
// 13-11: kind: 'library' is the one addition this wave makes to the
|
||||
// discriminator — it resolves to music.list-tracks, not content.*,
|
||||
// since a library track carries real tag-extracted metadata
|
||||
|
||||
@@ -90,12 +90,21 @@ export class ContextBroker {
|
||||
private iframe: Ref<HTMLIFrameElement | null>
|
||||
private allowedOrigin: string
|
||||
private listener: ((e: MessageEvent) => void) | null = null
|
||||
/** Monotonically-increasing content-request sequence — the AIUI-03
|
||||
* concurrency guard. Each `handleContentRequest` call captures its own
|
||||
* sequence number before awaiting the RPC(s); if a newer content:request
|
||||
* has started by the time it resolves, its result is discarded instead
|
||||
* of posted, so a slow response can never overwrite fresher grid data. */
|
||||
private contentRequestSeq = 0
|
||||
/** Per-(kind, scope) content-request sequence — the AIUI-03 concurrency
|
||||
* guard. Each `handleContentRequest` call captures its own sequence number
|
||||
* before awaiting the RPC(s); if a newer request FOR THE SAME kind+scope
|
||||
* has started by the time it resolves, its result is discarded, so a slow
|
||||
* response can never overwrite fresher grid data.
|
||||
*
|
||||
* Keyed rather than global. A single shared counter made different kinds
|
||||
* cancel each other: `useArchy.ts` init fires `content('all','own')` and
|
||||
* `library('own')` back to back, both sequence numbers are assigned
|
||||
* synchronously before either awaits, so the first request ALWAYS resolved
|
||||
* with a stale number and was dropped — films, podcasts and own files never
|
||||
* reached the grid and only music ever did. Different kinds populate
|
||||
* different grids and cannot stale each other by definition; only a newer
|
||||
* request for the same grid can. */
|
||||
private contentRequestSeq = new Map<string, number>()
|
||||
|
||||
/** 13-08: how often the broker asks the node for a pending destructive-
|
||||
* tool confirmation while a chat turn is in flight. The node's loop is
|
||||
@@ -368,9 +377,11 @@ export class ContextBroker {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++this.contentRequestSeq
|
||||
const requestedScope: 'own' | 'peers' | 'owned' =
|
||||
scope === 'peers' || scope === 'owned' ? scope : 'own'
|
||||
const seqKey = `${kind}:${requestedScope}`
|
||||
const seq = (this.contentRequestSeq.get(seqKey) ?? 0) + 1
|
||||
this.contentRequestSeq.set(seqKey, seq)
|
||||
|
||||
// 13-11: 'library' is the one kind value this wave adds — it resolves
|
||||
// to music.list-tracks (real tag-extracted metadata) instead of
|
||||
@@ -378,10 +389,10 @@ export class ContextBroker {
|
||||
const bundle =
|
||||
kind === 'library' ? await this.fetchLibraryContent() : await this.fetchAdaptedContent(requestedScope)
|
||||
|
||||
// Stale-response guard: a newer content:request has since started —
|
||||
// discard this result instead of flipping the grids back to older
|
||||
// data (AIUI-03 concurrency edge).
|
||||
if (seq !== this.contentRequestSeq) return
|
||||
// Stale-response guard: a newer content:request for this same kind+scope
|
||||
// has since started — discard this result instead of flipping that grid
|
||||
// back to older data (AIUI-03 concurrency edge).
|
||||
if (seq !== this.contentRequestSeq.get(seqKey)) return
|
||||
|
||||
this.postToIframe({
|
||||
type: 'content:push',
|
||||
|
||||
Reference in New Issue
Block a user