fix(client): backfill cold relatives' composition in the Agent network
The session-list block serves projection values only from the host's projection cache, so a relative that never attached since the timeline unit last changed lists pressure-only (occupancy without composition) and its ring degraded to the single fallback arc — every subagent ring looked green until its session was opened. The card now fetches those nodes' slim heads from the /api detail route (one read per session per page, failures sticky) and re-folds the forest with the row's missing contextTimeline injected; a row's own value always wins, and any fetch failure degrades back to the pressure-only ring.
This commit is contained in:
1 parent
a82e6a1dc7
commit
dae94c225a
5 files changed
+290
-9
No files matched your search
@@ -67,7 +67,11 @@ These checks prove source-level seam compatibility, statistical parity with the
|
||||
|
||||
## Upgrading from an older plugin build
|
||||
|
||||
The skill-injection category (issue #66) bumps the timeline projection's `stateVersion` (18 → 19): skill content — the `<available_skills>` catalog digest, a user-explicit `/name` invocation's instructions message, and the content a `skill`-tool load returns — now folds into its own `skill` composition bucket instead of `inject`/`tool`. The re-bucketing changes the fold's per-category sums, so on upgrade every cached per-session row is invalidated and re-folded from the durable log, which rebuilds them under the new categories. The bump orphans the `contextTimeline` key for idle sessions, which have no refresh channel until they go live again — such a session re-folds when its log receives its next event. New sessions are exact from their first event. To force a full refold of an existing session sooner, delete its cached projection row — it is a derived cache and is rebuilt from the durable log:
|
||||
The skill-injection category (issue #66) bumps the timeline projection's `stateVersion` (18 → 19): skill content — the `<available_skills>` catalog digest, a user-explicit `/name` invocation's instructions message, and the content a `skill`-tool load returns — now folds into its own `skill` composition bucket instead of `inject`/`tool`. The re-bucketing changes the fold's per-category sums, so on upgrade every cached per-session row is invalidated and re-folded from the durable log, which rebuilds them under the new categories. The bump orphans the `contextTimeline` key for idle sessions, which have no refresh channel until they go live again — such a session re-folds when its log receives its next event. New sessions are exact from their first event.
|
||||
|
||||
The Agent network card covers the orphaned relatives without waiting for a visit: a session listed without a `contextTimeline` row (composition absent, occupancy only) fetches its slim head from the plugin's `/api/dsh-context/detail` fetch route (mounted through Connection's exact-route registry, behind the same authenticated `/api` fence), which folds the durable log on demand — the node's ring renders the full composition the first time the card opens. The same backfill serves sessions whose cache predates the plugin's installation entirely. Where the route is unavailable (the baseline gate, a connection service without the exact-route registry), the card degrades to the pressure-only occupancy ring as before.
|
||||
|
||||
To force a full refold of an existing session sooner, delete its cached projection row — it is a derived cache and is rebuilt from the durable log:
|
||||
|
||||
```bash
|
||||
rm ~/.dsh/storages/session_projcache/sessions/<session-id>.json
|
||||
|
||||
+18
-2
@@ -220,8 +220,20 @@ interface AgentChild {
|
||||
* no current session, or a snapshot without a `byId` table (older harness).
|
||||
* The current session synthesizes a row when the list has not delivered it
|
||||
* yet, so the card can still show its live self stats.
|
||||
* @param fetched - detail-channel heads for COLD rows, keyed by session id.
|
||||
* The host's list block serves projection values only from the projection
|
||||
* cache, so a relative that never attached since the timeline unit last
|
||||
* changed lists pressure-only (no composition row) and its ring would
|
||||
* degrade to the occupancy arc; the card fetches those heads on demand.
|
||||
* A row's own value always wins — the injection lands only on rows whose
|
||||
* `contextTimeline` is absent.
|
||||
*/
|
||||
export function agentForestOf(snapshot: unknown, currentId: string | undefined, self?: AgentSelfStats): AgentForest | null {
|
||||
export function agentForestOf(
|
||||
snapshot: unknown,
|
||||
currentId: string | undefined,
|
||||
self?: AgentSelfStats,
|
||||
fetched?: ReadonlyMap<string, unknown>,
|
||||
): AgentForest | null {
|
||||
const byId = asRecord(asRecord(snapshot)?.byId)
|
||||
if (byId === null || currentId === undefined || currentId === '') return null
|
||||
|
||||
@@ -284,7 +296,11 @@ export function agentForestOf(snapshot: unknown, currentId: string | undefined,
|
||||
const visit = (id: string, row: AgentRow, parentId: string | undefined, depth: number, seen: Set<string>, family: number): void => {
|
||||
if (seen.has(id) || nodes.length >= AGENT_TREE_LIMIT) return
|
||||
seen.add(id)
|
||||
const stats = agentStatsOf(row.projections)
|
||||
const values = row.projections
|
||||
const fetchedHead = fetched !== undefined ? fetched.get(id) : undefined
|
||||
const stats = agentStatsOf(fetchedHead !== undefined && values?.contextTimeline === undefined
|
||||
? { ...values, contextTimeline: fetchedHead }
|
||||
: values)
|
||||
const identity = stats.identity
|
||||
const node: AgentNode = {
|
||||
...stats,
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
*
|
||||
* Data rides the harness's existing planes end to end — the session-list
|
||||
* snapshot (`ctx.sessions.list`: lineage rows + per-session projection
|
||||
* values) and the tab's own projections for the current node — so the card
|
||||
* adds no RPC of its own beyond one direct-child catalog refresh per
|
||||
* session. A harness without the outward sessions service hides the card.
|
||||
* values) and the tab's own projections for the current node. The list block
|
||||
* serves projection values only from the host's projection cache, so a
|
||||
* relative that never attached since the timeline unit last changed lists
|
||||
* pressure-only (occupancy without composition); those nodes fetch their slim
|
||||
* head from the plugin's `/api` detail route (the one call below) and
|
||||
* re-render composed. A harness without the outward sessions service hides
|
||||
* the card.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactElement } from 'react'
|
||||
@@ -16,6 +20,7 @@ import { CATS } from '../categories'
|
||||
import { containHorizontalOverscroll } from '../overscroll'
|
||||
import type { ClientCtx } from '../services'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import type { ContextTimeline } from '../../shared/types'
|
||||
import type { AgentNode, AgentSelfStats } from '../agentTree'
|
||||
import {
|
||||
AGENT_NODE_R,
|
||||
@@ -27,6 +32,7 @@ import {
|
||||
ringSegments,
|
||||
sessionsFaceOf,
|
||||
} from '../agentTree'
|
||||
import { makeDetailFetcher } from '../timelineSource'
|
||||
|
||||
export interface AgentGraphProps {
|
||||
sessionId?: string
|
||||
@@ -51,6 +57,12 @@ export function makeAgentGraph(
|
||||
): (props: AgentGraphProps) => ReactElement | null {
|
||||
const { t, fmt, catLabel } = kit
|
||||
|
||||
/* Cold-relative head fetches: one in-flight-or-settled promise per session
|
||||
id for the factory's lifetime (page scope). A settled null — transport
|
||||
failure, hostile payload, route absent, session left the live set — is
|
||||
sticky, so a broken relative never retries per snapshot tick. */
|
||||
const heads = new Map<string, Promise<ContextTimeline | null>>()
|
||||
|
||||
function AgentGraph(props: AgentGraphProps): ReactElement | null {
|
||||
// Resolved lazily at mount (not at apply): the outward sessions service
|
||||
// belongs to the client runtime's composition, and a deployment without
|
||||
@@ -105,10 +117,32 @@ export function makeAgentGraph(
|
||||
face.refreshSubagents(sessionId).catch(() => {})
|
||||
}, [face, sessionId])
|
||||
|
||||
// Composition heads fetched for cold relatives (see the effect below):
|
||||
// landed values re-fold the forest with the row's missing `contextTimeline`
|
||||
// injected.
|
||||
const [landed, setLanded] = useState<ReadonlyMap<string, ContextTimeline>>(new Map())
|
||||
|
||||
const built = useMemo(() => {
|
||||
const forest = agentForestOf(snapshot, sessionId, props.self)
|
||||
const forest = agentForestOf(snapshot, sessionId, props.self, landed)
|
||||
return forest !== null ? { forest, layout: layoutForest(forest, stageWidth) } : null
|
||||
}, [snapshot, sessionId, props.self, stageWidth])
|
||||
}, [snapshot, sessionId, props.self, stageWidth, landed])
|
||||
|
||||
// Nodes with no composition (occupancy-only, or nothing listed at all —
|
||||
// the projection cache holds no timeline row for either) fetch their slim
|
||||
// head off the detail channel and re-render composed. The current node is
|
||||
// excluded: the tab's own projections already feed it live.
|
||||
useEffect(() => {
|
||||
if (built === null) return
|
||||
for (const n of built.forest.nodes) {
|
||||
if (n.isCurrent || (n.head !== null && n.head.parts.length > 0) || heads.has(n.id)) continue
|
||||
const fetcher = makeDetailFetcher(ctx, n.id)
|
||||
const pending = fetcher !== undefined ? fetcher().then(d => d?.head ?? null) : Promise.resolve(null)
|
||||
heads.set(n.id, pending)
|
||||
pending.then((head) => {
|
||||
if (head !== null) setLanded(prev => new Map(prev).set(n.id, head))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [built])
|
||||
|
||||
if (built === null) return null
|
||||
const { forest, layout } = built
|
||||
|
||||
@@ -208,6 +208,70 @@ describe('agentForestOf', () => {
|
||||
assert.ok(withRow.nodes[0].head !== null)
|
||||
})
|
||||
|
||||
test('injects fetched cold heads into rows whose own timeline is absent', () => {
|
||||
// A multi-category composition (the detail channel's slim head).
|
||||
const composed: ContextTimeline = {
|
||||
ok: true,
|
||||
contextWindow: 1000,
|
||||
current: { system: 100, tools: 50, user: 200, inject: 50, skill: 0, assistant: 150, tool: 50, total: 600 },
|
||||
requests: [],
|
||||
events: [],
|
||||
nodes: [],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
}
|
||||
const forest = agentForestOf(snap({
|
||||
root: row({ displayTitle: 'Root' }),
|
||||
cold: row({
|
||||
parentId: 'root', origin: 'subagent', updatedAt: 2,
|
||||
projectionValues: { contextPressure: { projectedTokens: 500, contextWindow: 1000 } },
|
||||
}),
|
||||
warm: row({ parentId: 'root', projectionValues: { contextTimeline: timeline(100, 1) } }),
|
||||
}), 'root', undefined, new Map<string, unknown>([
|
||||
['cold', composed],
|
||||
['warm', composed],
|
||||
['ghost', timeline(5)],
|
||||
]))
|
||||
assert.ok(forest !== null)
|
||||
// Pressure anchors the occupancy; the fetched head supplies the composition arcs.
|
||||
const cold = forest.nodes.find(n => n.id === 'cold')
|
||||
assert.ok(cold !== undefined && cold.head !== null)
|
||||
assert.equal(cold.head.tokens, 500)
|
||||
assert.equal(cold.head.pct, 50)
|
||||
assert.ok(cold.head.parts.filter(p => p.value > 0).length >= 5, 'composed, not the single occupancy arc')
|
||||
// A row's own timeline outranks the injection; unrelated ids never inject.
|
||||
const warm = forest.nodes.find(n => n.id === 'warm')
|
||||
assert.equal(warm?.head?.tokens, 100)
|
||||
assert.ok(forest.nodes[0].head === null)
|
||||
// A row without projections folds from the fetched head alone.
|
||||
const solo = agentForestOf(snap({
|
||||
s1: row({}),
|
||||
}), 's1', undefined, new Map([['s1', composed]]))
|
||||
assert.ok(solo !== null)
|
||||
const soloHead = solo.nodes[0].head
|
||||
assert.ok(soloHead !== null)
|
||||
assert.equal(soloHead.tokens, 600, 'no pressure anchor: the fold total carries')
|
||||
assert.equal(soloHead.pct, 60)
|
||||
})
|
||||
|
||||
test('a hostile fetched head degrades back to the pressure-only ring', () => {
|
||||
const byId = {
|
||||
root: row({ displayTitle: 'Root' }),
|
||||
cold: row({
|
||||
parentId: 'root', origin: 'subagent', updatedAt: 2,
|
||||
projectionValues: { contextPressure: { projectedTokens: 500, contextWindow: 1000 } },
|
||||
}),
|
||||
}
|
||||
for (const junk of ['garbage', null, 42]) {
|
||||
const forest = agentForestOf(snap(byId), 'root', undefined, new Map([['cold', junk]]))
|
||||
assert.ok(forest !== null)
|
||||
const cold = forest.nodes.find(n => n.id === 'cold')
|
||||
assert.ok(cold !== undefined && cold.head !== null)
|
||||
assert.equal(cold.head.tokens, 500, 'pressure still anchors the ring')
|
||||
assert.equal(cold.head.parts.length, 0, 'no composition invented from junk')
|
||||
}
|
||||
})
|
||||
|
||||
test('walks the lineage up to the topmost ancestor and DFSes the subtree', () => {
|
||||
const forest = agentForestOf(snap({
|
||||
root: row({ displayTitle: 'Root', running: true, updatedAt: 5 }),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { act, createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { afterEach, describe, test, vi } from 'vitest'
|
||||
import { makeAgentGraph, ringColorOf } from '../../../src/client/components/agentGraph'
|
||||
import type { AgentSelfStats } from '../../../src/client/agentTree'
|
||||
import { TestClientCtx, asClientCtx } from '../helpers/harness'
|
||||
@@ -398,3 +398,166 @@ describe('ringColorOf', () => {
|
||||
assert.equal(ringColorOf(12), 'var(--color-green-500)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentGraph — cold-relative composition fetch', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** A multi-category slim head (the detail payload's cold-node field). */
|
||||
function composedHead(): unknown {
|
||||
return {
|
||||
ok: true,
|
||||
contextWindow: 1000,
|
||||
current: { system: 100, tools: 50, user: 200, inject: 50, skill: 0, assistant: 150, tool: 50, total: 600 },
|
||||
requests: [],
|
||||
events: [],
|
||||
nodes: [],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** A detail payload without the head (the collections-only shape). */
|
||||
function detailValue(head: unknown): Record<string, unknown> {
|
||||
return {
|
||||
rev: 1,
|
||||
...(head !== undefined ? { head } : {}),
|
||||
requests: [],
|
||||
events: [],
|
||||
nodes: [],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A programmable global fetch recording its reads: POSTs resolve `value`
|
||||
* (or throw, or hold until released).
|
||||
*/
|
||||
function detailRpc(options: { reject?: boolean; head?: unknown; nullValue?: boolean; defer?: boolean } = {}) {
|
||||
const calls: string[] = []
|
||||
let release: ((value: unknown) => void) | undefined
|
||||
vi.stubGlobal('fetch', async (url: unknown, init: { body: string }) => {
|
||||
const sessionId = (JSON.parse(String(init?.body)) as { sessionId?: unknown }).sessionId
|
||||
calls.push(`${String(url)}:${String(sessionId)}`)
|
||||
if (options.reject) throw new Error('transport down')
|
||||
if (options.defer) {
|
||||
return await new Promise((resolve) => { release = resolve })
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, value: options.nullValue ? null : detailValue(options.head) }),
|
||||
}
|
||||
})
|
||||
return { calls, release: (value: unknown) => release?.(value) }
|
||||
}
|
||||
|
||||
function makeFetchingView(): { View: ReturnType<typeof makeView>; face: FakeSessions } {
|
||||
const face = new FakeSessions(family())
|
||||
const ctx = new TestClientCtx({ services: { sessions: face } })
|
||||
return { View: makeAgentGraph(asClientCtx(ctx), kit), face }
|
||||
}
|
||||
|
||||
test('pressure-only relatives fetch their head and re-render composed', async () => {
|
||||
const rpc = detailRpc({ defer: true })
|
||||
const { View, face } = makeFetchingView()
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
|
||||
// The cold relative fetched at mount; until the read lands it wears the
|
||||
// pressure-only fused ring (arc + free outline).
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.equal(query(m.container, 'g[data-agent="done"]').querySelectorAll('circle.lc-agent-seg').length, 2)
|
||||
|
||||
await act(async () => {
|
||||
rpc.release({ ok: true, status: 200, json: async () => ({ ok: true, value: detailValue(composedHead()) }) })
|
||||
})
|
||||
await flush()
|
||||
// The head re-folds the ring: six composition arcs + the free remainder.
|
||||
const composed = query(m.container, 'g[data-agent="done"]')
|
||||
assert.equal(composed.querySelectorAll('circle.lc-agent-seg').length, 7)
|
||||
assert.ok(text(composed).includes('95%'), 'the pressure-anchored occupancy never changes')
|
||||
|
||||
// A snapshot tick re-fetches nothing (the promise cache dedups).
|
||||
await act(async () => {
|
||||
face.setState(family())
|
||||
})
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a failing fetch degrades to the pressure-only ring and never retries', async () => {
|
||||
const rpc = detailRpc({ reject: true })
|
||||
const { View, face } = makeFetchingView()
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.equal(query(m.container, 'g[data-agent="done"]').querySelectorAll('circle.lc-agent-seg').length, 2)
|
||||
// A later snapshot tick (fresh forest, still pressure-only) re-fetches nothing.
|
||||
await act(async () => {
|
||||
face.setState(family())
|
||||
})
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'], 'the sticky failure never re-fetches')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a hostile head drops alone and a null value degrades the same way', async () => {
|
||||
for (const options of [{ head: 'garbage' }, { nullValue: true }]) {
|
||||
const rpc = detailRpc(options)
|
||||
const { View } = makeFetchingView()
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.equal(query(m.container, 'g[data-agent="done"]').querySelectorAll('circle.lc-agent-seg').length, 2)
|
||||
assert.ok(text(query(m.container, 'g[data-agent="done"]')).includes('95%'))
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('a hostile empty session id degrades to the pressure-only ring without fetching', async () => {
|
||||
const rpc = detailRpc({ head: composedHead() })
|
||||
const face = new FakeSessions({
|
||||
root: { displayTitle: 'Main', running: false, updatedAt: 10 },
|
||||
'': {
|
||||
parentId: 'root', origin: 'subagent', updatedAt: 2,
|
||||
projectionValues: { contextPressure: { projectedTokens: 400, contextWindow: 1000 } },
|
||||
},
|
||||
})
|
||||
const ctx = new TestClientCtx({ services: { sessions: face } })
|
||||
const View = makeAgentGraph(asClientCtx(ctx), kit)
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, [], 'an empty id never opens the detail route')
|
||||
assert.equal(query(m.container, 'g[data-agent=""]').querySelectorAll('circle.lc-agent-seg').length, 2)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a head with no occupancy anchor still composes from the fold alone', async () => {
|
||||
// No pressure on the row: the fetched head's fold total and window carry the ring.
|
||||
const rpc = detailRpc({ head: composedHead(), defer: true })
|
||||
const face = new FakeSessions({
|
||||
root: { displayTitle: 'Main', running: false, updatedAt: 10 },
|
||||
cold: {
|
||||
parentId: 'root', origin: 'subagent', updatedAt: 2,
|
||||
projectionValues: { subagent: { mode: 'one-shot', label: 'chill' } },
|
||||
},
|
||||
})
|
||||
const ctx = new TestClientCtx({ services: { sessions: face } })
|
||||
const View = makeAgentGraph(asClientCtx(ctx), kit)
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
assert.equal(query(m.container, 'g[data-agent="cold"]').querySelectorAll('circle.lc-agent-seg').length, 0)
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:cold'])
|
||||
|
||||
await act(async () => {
|
||||
rpc.release({ ok: true, status: 200, json: async () => ({ ok: true, value: detailValue(composedHead()) }) })
|
||||
})
|
||||
await flush()
|
||||
const composed = query(m.container, 'g[data-agent="cold"]')
|
||||
assert.equal(composed.querySelectorAll('circle.lc-agent-seg').length, 7, 'six arcs + the free remainder (no pressure arc)')
|
||||
assert.ok(text(composed).includes('60%'), 'the fold total prices against the head window')
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user