fix: address review round four
- the cached-identity rung gains a finality gate: the identity value carries its descriptor seq and a cached row is served only when that seq lands in the child's own suffix, so a fork seed's replayed ancestor identity can never outrank the authoritative refold (stateVersion bumped for the state-shape change) - the cold preparation validates the inspected header against the enumerated candidate's lifecycle witness; a republished id degrades to that child's corrupt diagnostic instead of leaking the new owner's log - the new projection registration proves HMR disposal; companion notes qualify the superseded decision text and record the deliberate error-face asymmetry
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: 92c8222381338f71c7d444da80c23098aba247b3
|
||||
README.zh.md: 8f61379bb789de6c1b97b22db034698009d0619c
|
||||
README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829
|
||||
README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902
|
||||
|
||||
@@ -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; 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.
|
||||
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 it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). 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_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`。
|
||||
面向模型的工具默认同步收集:先等待子 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 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 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 历史虚构目录条目。
|
||||
|
||||
|
||||
@@ -290,8 +290,9 @@ export class SubagentService extends Service {
|
||||
* serves each child's durable mode/label from the registered `subagent`
|
||||
* 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
|
||||
* row when the optional cache serves an own-suffix identity (its `seq`
|
||||
* gate proves the value postdates the fork seed, where a child's own
|
||||
* descriptor is immutable once appended), 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,
|
||||
|
||||
@@ -216,12 +216,13 @@ export async function listChildren(
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* projection-cache row when it serves an own-suffix identity (the seq gate),
|
||||
* 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; an inspection naming another lifecycle, and a
|
||||
* settled log the fold cannot identify — or that makes any registered unit
|
||||
* throw — are final, so they report `corrupt`.
|
||||
*/
|
||||
async function resolveColdIdentity(
|
||||
persistence: SessionPersistence,
|
||||
@@ -242,19 +243,22 @@ async function resolveColdIdentity(
|
||||
// row of ANY unit) silently falls through to the authoritative re-fold.
|
||||
cached = undefined
|
||||
}
|
||||
// A served identity is immutable once appended, so a cached one is final
|
||||
// regardless of the row's watermark. Both no-value forms fall through to
|
||||
// preparation: an absent key (a checkpoint cut before the descriptor was
|
||||
// appended) and the `null` sentinel, whose verdict belongs to the
|
||||
// authoritative re-fold, not to a derived row.
|
||||
if (cached !== undefined && cached !== null) {
|
||||
// A child's OWN descriptor is immutable once appended, so a cached
|
||||
// identity is final only when the seq gate proves it was folded from the
|
||||
// own suffix: a creation-window checkpoint may instead carry a fork
|
||||
// seed's replayed ANCESTOR descriptor (seq below `seedLength`), which
|
||||
// must not outrank the re-fold. Everything else also falls through to
|
||||
// preparation: an absent key (a cut before any descriptor) and the
|
||||
// `null` sentinel, whose verdict belongs to the authoritative re-fold,
|
||||
// not to a derived row.
|
||||
if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) {
|
||||
return childRow(childId, cached, 'inactive', hasChildren)
|
||||
}
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
let events: readonly SessionEvent[]
|
||||
let inspected: { meta: SessionHeader; events: readonly SessionEvent[] }
|
||||
try {
|
||||
events = (await persistence.inspect(childId, signal)).events
|
||||
inspected = await persistence.inspect(childId, signal)
|
||||
} catch {
|
||||
// Per-child isolation: the child vanished or its backend read failed —
|
||||
// one diagnostic row, and the listing itself still succeeds.
|
||||
@@ -262,9 +266,15 @@ async function resolveColdIdentity(
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
// A session id names a slot, not a lifecycle: a child deleted and
|
||||
// re-published under another owner between the enumeration and this read
|
||||
// must not leak into the old parent's listing.
|
||||
if (!sameLifecycle(inspected.meta, header)) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.restore({}, events, 0).snapshot.values.subagent
|
||||
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
|
||||
} catch {
|
||||
// The restore folds EVERY registered unit over this child's log, so any
|
||||
// unit's fold or schema can reject damaged payloads — deterministic data
|
||||
@@ -303,6 +313,19 @@ function childRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable header fields that distinguish one session lifecycle from another under the same id. */
|
||||
const LIFECYCLE_WITNESS_KEYS = [
|
||||
'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Whether an inspected log still belongs to the enumerated lifecycle,
|
||||
* mirroring the retired query-source compatibility check's field set.
|
||||
*/
|
||||
function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean {
|
||||
return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key])
|
||||
}
|
||||
|
||||
/** Stop a listing at its next cancellation checkpoint. */
|
||||
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
|
||||
@@ -29,12 +29,21 @@ export type SubagentIdentityProjection =
|
||||
mode: 'one-shot'
|
||||
/** Optional durable creation label from the child's descriptor. */
|
||||
label?: string
|
||||
/**
|
||||
* Seq of the `subagent/descriptor` event this identity was folded from.
|
||||
* `seq >= header.seedLength` proves the identity comes from the child's
|
||||
* OWN log suffix — where a descriptor is immutable once appended — and
|
||||
* not from a fork seed's replayed ancestor descriptor.
|
||||
*/
|
||||
seq: number
|
||||
}
|
||||
| {
|
||||
/** A resumable conversation. */
|
||||
mode: 'continuable'
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
label: string
|
||||
/** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
|
||||
@@ -99,10 +99,12 @@ const identitySchema = z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
mode: z.literal('one-shot'),
|
||||
label: z.string().optional(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
z.object({
|
||||
mode: z.literal('continuable'),
|
||||
label: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
|
||||
|
||||
@@ -118,8 +120,12 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u
|
||||
}
|
||||
if (descriptor === undefined) return undefined
|
||||
return descriptor.mode === 'one-shot'
|
||||
? { mode: 'one-shot', ...descriptor.label !== undefined ? { label: descriptor.label } : {} }
|
||||
: { mode: 'continuable', label: descriptor.label }
|
||||
? {
|
||||
mode: 'one-shot',
|
||||
...descriptor.label !== undefined ? { label: descriptor.label } : {},
|
||||
seq: event.seq,
|
||||
}
|
||||
: { mode: 'continuable', label: descriptor.label, seq: event.seq }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,5 +150,7 @@ ProjectionDefinition<'subagent', IdentityState> = {
|
||||
return identity === undefined ? {} : { identity }
|
||||
},
|
||||
view: state => state.identity ?? null,
|
||||
stateVersion: 1,
|
||||
// Bumped when the identity gained its `seq` field: an older checkpoint row
|
||||
// would replay into a value the schema rejects, so it must refold instead.
|
||||
stateVersion: 2,
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('SubagentService.listChildren', () => {
|
||||
live.append('turn/start', { turn: 1 })
|
||||
live.append('subagent/descriptor', descriptorPayload('was valid'))
|
||||
expect(ctx.sessionProjections.snapshot(live).values.subagent)
|
||||
.toEqual({ mode: 'continuable', label: 'was valid' })
|
||||
.toEqual({ mode: 'continuable', label: 'was valid', seq: 1 })
|
||||
// Last-wins: the malformed follow-up resets the identity to the sentinel.
|
||||
live.append(
|
||||
'subagent/descriptor',
|
||||
@@ -409,6 +409,82 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('serves a cached own-suffix identity directly without inspection', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const child = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('disk label')))
|
||||
// seq 2 >= seedLength 0: the cached identity provably comes from the
|
||||
// child's own suffix, so it is final and the log is never re-read — the
|
||||
// divergent label proves the row, not the log, produced the entry.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({
|
||||
asOfSeq: 2,
|
||||
values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: child, label: 'cached own', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a cached ancestor identity from the fork seed and lets preparation rule', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
// A fork child: the seed replays the ancestor's descriptor (seq 2), and
|
||||
// the child's own descriptor arrives in its first own turn (seq 5).
|
||||
const seed = childEvents(descriptorPayload('ancestor label'))
|
||||
const events = [
|
||||
...seed,
|
||||
{ type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', {
|
||||
parentSession: parent.id,
|
||||
seedLength: seed.length,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
// A creation-window checkpoint carried the ANCESTOR identity: its seq 2
|
||||
// fails the own-suffix gate (< seedLength 4), so preparation rules.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({
|
||||
asOfSeq: 2,
|
||||
values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })],
|
||||
['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })],
|
||||
] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const reborn = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae03', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('reborn child')))
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
|
||||
const result = await original(sessionId, signal)
|
||||
if (sessionId !== reborn) return result
|
||||
// The id was re-published as a different lifecycle after enumeration.
|
||||
return { ...result, meta: mutate(result.meta) }
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets preparation rule when the cache serves the null sentinel', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {
|
||||
|
||||
@@ -23,11 +23,15 @@ describe('subagent timing projection', () => {
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const serviceFiber = await ctx.plugin(SubagentService)
|
||||
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toEqual({ settledMs: 0 })
|
||||
const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
|
||||
expect(before.subagentTiming).toEqual({ settledMs: 0 })
|
||||
// The identity unit registers alongside timing; an empty log serves its
|
||||
// serializable null sentinel.
|
||||
expect(before.subagent).toBeNull()
|
||||
await serviceFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toBeUndefined()
|
||||
const after = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
|
||||
expect(after.subagentTiming).toBeUndefined()
|
||||
expect(after.subagent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
|
||||
|
||||
Reference in New Issue
Block a user