feat(subagent): opportunistic projection-cache rung for cold listings
Cold children consult the optional session-projection-cache checkpoint before paying a preparation recompute: the identity is immutable once appended, so a cached value is definitive regardless of its watermark. The cache stays a read-only accelerator (absent service or any rung-two fault falls through silently; verdicts stay with the authoritative refold), and the note plus core-data-structures pages describe the three-rung ladder.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: bfed362d5a70bf946295c04d02ed1c6d031041e3
|
||||
README.zh.md: 11121735bd4acdfccf2ef950d30e5913646430a4
|
||||
README.md: b89fcb4b4d318c872117078b6c49822d0633fd31
|
||||
README.zh.md: 6309a9b8ad4ac9edf5ccf994594638cc9f175684
|
||||
|
||||
@@ -84,7 +84,7 @@ When `ctx.sessionProjections` is available, the service registers two projection
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child, one bounded-concurrency persistence inspection folded through the registry for a cold one. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache already serves the identity (immutable once appended, so staleness cannot matter), else one bounded-concurrency persistence inspection folded through the registry. A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照,冷 child 经一次有界并发的持久化 inspect 再经注册表折叠。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行——身份一经追加即不可变,故无须关心行的新旧——命中即用,否则经一次有界并发的持久化 inspect 再经注册表折叠。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
|
||||
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -52,6 +53,9 @@
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection-cache": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -65,6 +69,9 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -288,8 +288,11 @@ export class SubagentService extends Service {
|
||||
* resuming an Agent and without any query seam: the listing merges the live
|
||||
* session store with optional session persistence (live-preferred) and
|
||||
* serves each child's durable mode/label from the registered `subagent`
|
||||
* projection unit — the registry's watermark snapshot for a live child, one
|
||||
* persistence inspection folded through the registry for a cold one. The
|
||||
* projection unit down a three-rung ladder — the registry's watermark
|
||||
* snapshot for a live child; for a cold one, a durable projection-cache
|
||||
* row when the optional cache already serves the identity (the value is
|
||||
* immutable, so staleness cannot matter), else one persistence inspection
|
||||
* folded through the registry. The
|
||||
* projection fold is the single classification authority; per-child
|
||||
* diagnostics relay a fold that served no identity or a failed inspection,
|
||||
* never a list-time descriptor parse. Absent persistence, enumeration is
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* from the live session store and optional session persistence — no query
|
||||
* seam. Candidates are the live-preferred merge of both listings filtered to
|
||||
* durable `origin: 'subagent'` under the parent; each child's mode/label is
|
||||
* the registered `subagent` projection unit's value, served from the
|
||||
* registry's watermark cache for a live child and folded once over one
|
||||
* persistence inspection for a cold one. The projection fold is the single
|
||||
* the registered `subagent` projection unit's value, resolved down a
|
||||
* three-rung ladder: the registry's watermark cache for a live child, a
|
||||
* durable projection-cache row when the optional cache already serves the
|
||||
* identity, and one persistence inspection folded through the registry
|
||||
* otherwise. The projection fold is the single
|
||||
* classification authority — this module parses no descriptor itself. Absent
|
||||
* persistence, enumeration is live-only: a cold child is unreachable for
|
||||
* resume anyway, so its absence is capability absence, not an error. The
|
||||
@@ -19,6 +21,7 @@ import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SubagentError } from './error.ts'
|
||||
import type { SubagentIdentityProjection } from './projection-types.ts'
|
||||
|
||||
@@ -88,11 +91,13 @@ export type SubagentListEntry =
|
||||
* Enumerate one parent's origin-classified direct children from the
|
||||
* live-preferred merge of `ctx.sessions` and optional session persistence,
|
||||
* serving each identity from the `subagent` projection unit: the registry's
|
||||
* watermark snapshot for a live child, one bounded-concurrency persistence
|
||||
* inspection folded through the registry for a cold one.
|
||||
* watermark snapshot for a live child; for a cold one, a durable
|
||||
* projection-cache row when the optional cache already serves the identity,
|
||||
* else one bounded-concurrency persistence inspection folded through the
|
||||
* registry.
|
||||
* @see SubagentService.listChildren for the public cancellation and failure contract.
|
||||
* @param ctx - context carrying the session store, the projection registry,
|
||||
* and optional persistence.
|
||||
* optional persistence, and the optional projection cache.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation observed around every persistence read.
|
||||
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
|
||||
@@ -126,6 +131,10 @@ export async function listChildren(
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
// Optional acceleration only: an absent cache service just means every
|
||||
// cold candidate takes the authoritative preparation rung, so it carries
|
||||
// no error code and no configuration check.
|
||||
const cache = ctx.get('sessionProjectionCache')
|
||||
let persistedHeaders: readonly SessionHeader[] = []
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
@@ -158,11 +167,11 @@ export async function listChildren(
|
||||
|| a.header.id.localeCompare(b.header.id))
|
||||
|
||||
const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
|
||||
const coldReads: { index: number; id: SessionId }[] = []
|
||||
const coldReads: { index: number; header: SessionHeader }[] = []
|
||||
candidates.forEach((candidate, index) => {
|
||||
const childId = candidate.header.id
|
||||
if (candidate.live === undefined) {
|
||||
coldReads.push({ index, id: childId })
|
||||
coldReads.push({ index, header: candidate.header })
|
||||
return
|
||||
}
|
||||
// The registry's watermark cache serves the live value with zero log
|
||||
@@ -191,8 +200,9 @@ export async function listChildren(
|
||||
{ length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
|
||||
async () => {
|
||||
for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
|
||||
rows[job.index] = await inspectColdIdentity(
|
||||
persistence, projections, job.id, subagentParents.has(job.id), signal,
|
||||
rows[job.index] = await resolveColdIdentity(
|
||||
persistence, projections, cache, job.header,
|
||||
subagentParents.has(job.header.id), signal,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -203,20 +213,38 @@ export async function listChildren(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one cold candidate: one persistence inspection folded through the
|
||||
* projection registry (the same detached recipe the API proxy uses for
|
||||
* detached session projections). A failed inspection is one transient
|
||||
* `unavailable` row retried on the next listing; a settled log the fold
|
||||
* cannot identify — or that makes any registered unit throw — is final, so
|
||||
* it reports `corrupt`.
|
||||
* Resolve one cold candidate down the remaining ladder: a durable
|
||||
* projection-cache row when it already serves the identity, otherwise one
|
||||
* persistence inspection folded through the projection registry (the same
|
||||
* detached recipe the API proxy uses for detached session projections). A
|
||||
* failed inspection is one transient `unavailable` row retried on the next
|
||||
* listing; a settled log the fold cannot identify — or that makes any
|
||||
* registered unit throw — is final, so it reports `corrupt`.
|
||||
*/
|
||||
async function inspectColdIdentity(
|
||||
async function resolveColdIdentity(
|
||||
persistence: SessionPersistence,
|
||||
projections: SessionProjectionRegistry,
|
||||
childId: SessionId,
|
||||
cache: SessionProjectionCache | undefined,
|
||||
header: SessionHeader,
|
||||
hasChildren: boolean,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<SubagentListEntry> {
|
||||
const childId = header.id
|
||||
if (cache !== undefined) {
|
||||
let cached: SubagentIdentityProjection | undefined
|
||||
try {
|
||||
cached = cache.cachedSnapshot(header)?.values.subagent
|
||||
} catch {
|
||||
// Unlike the preparation fold below, a throwing cache read renders no
|
||||
// verdict: the cache is derived data, so its damage (a poisoned stored
|
||||
// row of ANY unit) silently falls through to the authoritative re-fold.
|
||||
cached = undefined
|
||||
}
|
||||
// The identity is immutable once appended, so a cached value is final
|
||||
// regardless of the row's watermark; an absent key (a checkpoint cut
|
||||
// before the descriptor was appended) falls through to preparation.
|
||||
if (cached !== undefined) return childRow(childId, cached, 'inactive', hasChildren)
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
let events: readonly SessionEvent[]
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,10 @@ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import SubagentService, {
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
SubagentError,
|
||||
@@ -28,7 +32,10 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
/** Boot the continuable stack with real JSONL session persistence. */
|
||||
async function setup(script: Script, options: { sessionProjections?: boolean } = {}) {
|
||||
async function setup(
|
||||
script: Script,
|
||||
options: { sessionProjections?: boolean; projectionCache?: boolean } = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
|
||||
@@ -36,6 +43,14 @@ async function setup(script: Script, options: { sessionProjections?: boolean } =
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
|
||||
if (options.projectionCache === true) {
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool()))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
}
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
@@ -606,6 +621,78 @@ describe('SubagentService.listChildren', () => {
|
||||
expect(inspected).not.toContain(liveId)
|
||||
})
|
||||
|
||||
it('serves a cold child from the projection cache without any inspection', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')], { projectionCache: true })
|
||||
const childId = await startChild(ctx, parent, 'cached child')
|
||||
// The child's turn/end and disposal are the cache's mandatory checkpoint
|
||||
// points; both writes are fail-soft asynchronous, so wait for the row.
|
||||
const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
|
||||
}, { timeout: 5_000 })
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: childId, label: 'cached child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to inspection when the cache serves no identity for the child', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('uncached child')))
|
||||
const expected = [{
|
||||
kind: 'child', id: foreign, label: 'uncached child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}]
|
||||
// No stored row at all for a foreign child this process never ran.
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
// A stored row whose cut predates the descriptor: the subagent key is
|
||||
// absent from the served values, and preparation still rules.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
|
||||
expect(inspect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('takes the preparation rung directly when no projection cache is mounted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(ctx.get('sessionProjectionCache')).toBeUndefined()
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('uncacheable child')))
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('silently falls through to preparation when the cache read throws', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const recovered = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac03', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('recovered child')))
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => {
|
||||
// A poisoned stored row (any unit's) detonates at view time; the cache
|
||||
// is derived data, so its failure must not become a verdict.
|
||||
throw new Error('poisoned cache row')
|
||||
}
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not count an ordinary grandchild without subagent origin', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'direct child')
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user