DSH / Atlas
2026-08-06implementedarchitecture

Subagent list identity via the projection unit

subagent 列表经投影单元读取身份

Before the rewrite, `SubagentRuntime.listChildren` ran two full-log materializations — `listEvents` plus `readEvent` — on every listing for each direct child with `header.origin === 'subagent'`, each materialization accompanied by a full-log structuredClone, all to fold two fields, mode and label, out of the descriptor event. The descriptor's position in the log is not fixed — the fork prefix is arbitrarily long, and

English

Problem

Before the rewrite, SubagentRuntime.listChildren ran two full-log materializations — listEvents plus readEvent — on every listing for each direct child with header.origin === 'subagent', each materialization accompanied by a full-log structuredClone, all to fold two fields, mode and label, out of the descriptor event. The descriptor's position in the log is not fixed — the fork prefix is arbitrarily long, and zstd-compressed frames carry no seq index — so there is no shortcut to locating it; this path had no cache whatsoever, and its cost amplifies with transcript length × child count × listing frequency. It also dragged session-query in as a hard dependency of listing: in a deployment without a query backend, list_agents rejects wholesale with SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE, even though enumeration needs nothing but header facts.

The same root cause has a second symptom: on every Agent-bound RPC's owner check, the host-side hasSubagentDescriptor() scans the target session's own suffix, even though SessionHeader.origin already answers the vast majority of the same question.

The root cause is that the durable-subagent-catalog decision made the descriptor event (subagent/descriptor) the catalog's sole durable authority yet paired descriptor reads with no cache layer, and explicitly accepted the per-child double read as the "no-index correctness baseline". Web subagent conversations (#1569) already put "is this a subagent" into the header (SessionHeader.origin), so identity determination no longer reads the log; mode and label still had to be scanned.

Decision

mode and label are folded by the new subagent projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; listChildren no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional sessionProjectionCache checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full persistence.inspect read plus one registry.restore fold. No index, no cache of its own, no write-back.

There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered.

Key points:

  • The subagent list does not depend on session-query: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through ctx.sessionProjections; deployments without a query backend list as usual.
  • Value retrieval is a three-rung compute-and-discard ladder: a live child reads sessionProjections.snapshot() (the registry's existing watermark cache, zero log reads); a cold child first reads the optional sessionProjectionCache.cachedSnapshot(header), using the value directly when a non-null subagent identity passing the seq gate (seq >= seedLength ?? 0) is among its values; otherwise it pays one full persistence.inspect read plus one registry.restore({}, events, 0) fold; beyond that, absent is absent — no cache of its own, no write-back, no index.
  • The subagent projection unit is the sole authority over the fold rules: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists.
  • The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched; pre-existing data acquires exact values through one inspect computation the first time it is listed — no degraded unknown state, no migration.

Relationship to existing notes:

  • This note supersedes two designs on the list read path in durable-subagent-catalog: enumeration through sessionQuery.traceSession, and per-child descriptor-event reads (the listEvents-plus-exact-readEvent double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked.
  • The session-projection RFC's registry contract (ProjectionDefinition, snapshot, restore) is untouched; this note only adds one registration to it — the subagent identity unit — and becomes another consumer instance of the two existing reads, snapshot (live) and restore (cold) — GUI history's cold read is already the same shape. The fold rules are registered with the registry exactly once; every consuming surface computes through the registry, and no second copy of the fold logic exists.

subagent projection unit

It hangs beside the existing subagentTiming (projection.ts, projection-types.ts), under key subagent:

export type SubagentIdentityProjection =
  | { mode: 'one-shot'; label?: string; seq: number }
  | { mode: 'continuable'; label: string; seq: number }

declare module '@deepseek-ai/dsh-session-projection/types' {
  interface SessionProjectionMap {
    subagent: SubagentIdentityProjection | null
  }
}
  • The projection is pure identity, and the projection system has no failure channel: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a serializable null sentinel: the map entry is SubagentIdentityProjection | null, non-optional, never undefined or an absent key. The reason: the registry's onChanged push goes through JSON serialization, where an undefined field is dropped by stringify, the client's frame validation rejects the frame, and a consumer's stored old identity would never update; null passes frames intact, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the listChildren four-state mapping below).
  • Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no seq — it is the projection's internal own-suffix proof).
  • The identity carries seq: the seq of the subagent/descriptor event it was folded from, mandatory on both arms and absent on the null sentinel — seq >= header.seedLength ?? 0 proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The state gaining seq bumps the unit's stateVersion to 2, and existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
  • Fold rule: subagent/descriptor is last-wins, under the same descriptor-reset discipline as subagentTiming — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up.

Enumeration: subagent-owned live-preferred merge

listChildren's (list-children.ts) enumeration goes through no query service: the two sources ctx.sessions.list() and ctx.get('sessionPersistence')?.list() merge by id, with a live record overriding the same-id persisted record wholesale and no header consistency check. Everything enumeration needs is header facts:

  • Filtering: header.origin === 'subagent' && header.parentSession === parentSessionId.
  • hasChildren: the same merged material, looked at one level down — a direct descendant exists with origin === 'subagent' whose parentSession is that child.
  • activity: a live record is running; one present only in persistence is inactive.
  • Ordering: createdAt ascending, then child id ascending (matching the old contract).
  • Absent persistence degrades to live-only enumeration, not an error: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.)
  • A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads.

Value retrieval: the three-rung compute-and-discard ladder

For each enumerated child, mode/label retrieval walks a three-rung ladder — compute-and-discard, no cache of its own, no write-back (the third rung is the same shape as apiproxy session.history's cold read):

RungReadCost
1: live childctx.sessionProjections.snapshot(session).values.subagentZero log reads — the registry's existing watermark cache, synchronous retrieval
2: cold child, cache hitThe optional sessionProjectionCache.cachedSnapshot(header), used directly only when a non-null subagent identity satisfies identity.seq >= header.seedLength ?? 0 — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermarkZero log reads
3: cold child, fallbackOne full persistence.inspect(id) read + registry.restore({}, events, 0).snapshot.values.subagentOne full read computed per listing
  • Error contract: an unmounted ctx.sessionProjections is a configuration error; listChildren checks unconditionally before enumerating and fails loudly with SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent ctx.get('sessions') (a strict global read, never the caller-scope-bound property proxy) fails with SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE. The two codes map differently on the wire: apiproxy gives only PROJECTIONS_UNAVAILABLE a dedicated wire face, and SESSION_STORE_UNAVAILABLE goes through the generic internal fallback — the apiproxy composition injects sessions itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE is deleted along with the session-query dependency.
  • The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to sessionProjections' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating viewCheckpoint) silently falls to the third rung — the cache is derived data, so its faults never produce a corrupt verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the subagent key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict.
  • Per-child isolation: a single child's failed cold full read only turns that row into an unavailable diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping).
  • The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a corrupt row in the old parent's catalog, leaking nothing of the new owner's child.
  • Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated Config field.
  • The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through inspect() into the Session preparation cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout.
  • Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code CANCELLED.

Authority model

  • The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the sessionProjectionCache checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (an own descriptor is immutable once appended — a cached identity past the seq gate has no staleness problem; the gate guards against seed-replayed ancestor identities).
  • The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write.
  • Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces.

listChildren row shape and consuming surfaces

The SubagentListEntry data structure is identical to before the rewrite — the child and diagnostic arms, the kind discriminant, the three-valued reason, and the child arm's strong mode/label contract are all retained; the only change is the diagnostics' information source: the projection system has no failure channel, so diagnostics are derived by the list from projection-value absence and activity, and the list itself parses zero events. The "no value means await the hard read" rule guarantees the ladder always computes mode/label for healthy data.

export type SubagentListEntry =
  | ({
    readonly kind: 'child'
    readonly id: SessionId
    readonly activity: 'running' | 'inactive'
    readonly hasChildren: boolean
  } & (
    | { readonly mode: 'one-shot'; readonly label?: string }
    | { readonly mode: 'continuable'; readonly label: string }
  ))
  | {
    readonly kind: 'diagnostic'
    readonly id: SessionId
    readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
  }

For each enumerated child, the ladder's result maps to a row through four states:

Ladder resultRow
Snapshot carries a non-null subagent identitychild row
Snapshot present, subagent null sentinel or key absent, and the child is inactivediagnostic row, reason corrupt (settled debris: a missing, corrupt, or unrecognized-version descriptor, no longer subdivided)
Snapshot present, subagent null sentinel or key absent, and the child is runningno row (creation window: the descriptor is not yet appended — the same window the old implementation omitted)
The cold full read failsdiagnostic row, reason unavailable
  • unsupported is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced.
  • Descriptor-less settled debris moves from the old implementation's omit into the corrupt diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics.
  • Any registered unit whose fold/schema throws on this child's log is likewise contained as that child's diagnostic row, reason corrupt — a deterministic data fault, aligned with the old implementation's SESSION_QUERY_CORRUPT_SESSIONcorrupt mapping semantics; live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold throw means "the data is bad" — a poisoned running child also gets a corrupt row rather than an omit.

Known boundary deviations (deliberately accepted, recorded with this note):

  • A fork child that died in its publication window, with an ancestor descriptor in its seed, gets the ancestor identity from last-wins and wrongly surfaces as a child row; resume still fails against the own-suffix fold authority (NOT_RESUMABLE). The old implementation omitted it via seedLength filtering; the projection unit cannot see the header, and this debris-grade deviation is accepted (subagentTiming has the same kind of pre-existing exposure).
  • Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway).
  • A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row.
  • A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child corrupt; it is now uniformly an unavailable row (the read side cannot tell the causes apart).
  • An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted.
  • Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine.

Consuming surfaces: diagnostic handling across wire, tool, and GUI stays entirely as it was, zero changes (the list_agents description and output schema are untouched; the plugin only narrows its load requirement — sessionQuery dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the hasSubagentDescriptor() scan is deleted and hasSubagentOwner looks only at header.origin — pre-#1569 data without origin is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and subagents.history is aligned with session.history's source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from inspectServable reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the history JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm).

Change footprint

AreaFilesChange
subagentprojection.ts, projection-types.ts, index.tsNew subagent unit and its registration
subagentlist-children.ts and its typesRewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE replaced by SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent)
host/apiproxyapi-proxy.tshasSubagentDescriptor deleted; the owner check looks only at header.origin; subagents.history shares session.history's source — live from in-memory events and the registry's watermark snapshot, cold from inspectServable reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms retired with it
tooltool-subagent-control/list-agents.tsLoad requirement narrowed (sessionQuery dropped from inject); model-visible schema, description, and rendering unchanged
wire/clientapi/subagents.ts, runtime sessions/service.ts, GUITypes, row shape, and diagnostic handling unchanged; api/subagents.ts only reworded the history JSDoc to the dual arm
core/session, session-persistence, session-projection(-cache), session-query(-sqlite)Zero changes

Alternatives considered

mode/label into SessionHeader. The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one inspect computation on first listing", touching no durable format.

The projection-cache ladder (cachedSnapshot ?? coldSnapshot plus fail-soft write-back). The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent.

A bounded-read primitive on persistence to rescue pre-existing data. Opens a new persistence primitive for a one-time problem; superseded by the read-time inspect full read — the full read the first time pre-existing data is listed is itself the value retrieval.

Optional mode/label on list rows. Healthy data is always computable; optionality merely spills garbage-data handling complexity onto every consumer — each consuming surface has to grow filter branches and an unknown display state. The strong contract plus omit-when-uncomputable is cleaner.

Deleting diagnostic rows outright. Deletion turns corpus-corruption visibility into rows silently vanishing, and wire/tool/GUI would each have to absorb contract and snapshot changes; retention only asks the list side to derive the classification from projection-value absence and activity, at zero cost. That damaged, dead child sessions in the corpus must be visible is the original motivation for diagnostics' existence, and with retention the consuming surfaces stay wholly unchanged.

A registry computation failure channel (per-unit fault tolerance plus a supplementary failures field). To report corruption and unrecognized versions to consumers, the registry would catch unit exceptions and attach a per-key failure state beside the snapshot. Rejected: a failure is not a value and needs no channel — a unit never throws, absence is itself the signal, worst case the computation comes back empty, and how that is presented is the consumer's problem. An independent observation: the vendored Cordis emit (vendor/cordis/src/events.ts) catches nothing a listener throws, so with the projection driver hanging off session/event, a unit exception would escape along emit — which adds weight to the "a unit never throws" discipline, but fixing emit fault tolerance is outside this note's scope.

Values landed with query index preparation. Projection values folded into session index rows during the sqlite backend's reconciliation rebuild, for zero log reads in the steady read state: the projectionsFor bulk read face, the invalidation reconciliation of row values stored against the (key → stateVersion) registration set, and the SCHEMA bump. Retired wholesale: the direction was backwards — query infrastructure was forced to learn domain vocabulary (projection columns, registration-set reconciliation) while the sole consumer, the subagent list, is satisfied by read-time computation; with consumers down to zero, this derived persistence has no reason to exist. SESSION_QUERY_PROJECTIONS_UNAVAILABLE was deleted along with the read face.

Subagent hand-rolled parsing plus an in-process memo plus creation seeding. To excise the session-query dependency, the subagent package would parse descriptor events itself, avoid repeated full reads with an in-process memo, and seed initial values at creation. Superseded by the shipped ladder: live goes through the sessionProjections watermark cache and cold through registry.restore, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced.

DeepReadonly on the session-query output surface (a read-path overhaul experiment). Make the public query outputs deeply readonly to pin immutable borrowing at the type level. Rejected on evidence: 3 TS2589 occurrences (excessively deep type instantiation) plus 17 sites of array-position contagion (consumers' array methods and spread sites forced to follow); deep immutability is guaranteed by core/session's runtime deep freeze, and that read-path overhaul is not part of this note.

Verification

packages/subagent/subagent/tests/list-children.spec.ts is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE; a live child incurs zero inspect throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to corrupt; a cold-read failure maps to unavailable and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward hasChildren; createdAt-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to CANCELLED; the empty list and stable error codes. A hostile-unit dual-path probe (apply lazily poisons, view detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's corrupt row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Second-rung cases: an own-seq identity used directly with zero inspect, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to corrupt field by witness field (it.each over the seven). The tool-subagent-control list-agents tests are updated for the narrowed load requirement; optional-session-query.spec.ts is deleted with the dependency it guarded; the existing keyless snapshots (subagent-list-agents among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, subagent-diagnostic (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a corrupt row.

Consequences

  • Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full inspect read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it.
  • The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE is gone, and loading the list_agents plugin no longer requires sessionQuery.
  • Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee.
  • Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration.
  • The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from corrupt to unavailable, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted.
  • Pre-#1569 data without origin is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise.

Related

  • Durable subagent catalog and list_agents — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder.
  • Session projections and command lifecycle logging — the authority for the registry contract; this note adds the subagent identity unit to it and becomes a consumer instance of the two existing reads, snapshot and restore.
  • Web subagent conversations — the origin of SessionHeader.origin (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder.
  • Reusable Session preparation before publication — the inspect() cold read and LRU reuse; the cold child's full-read cost model builds on it.

中文

问题

重写前的 SubagentRuntime.listChildren 对每个 header.origin === 'subagent' 的直接 child,每次列表都执行 listEventsreadEvent 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript(文本记录)长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,list_agentsSUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE 整体拒绝,尽管枚举所需只是 header 事实。

同一根因还有第二个症状:host 侧的 hasSubagentDescriptor() 在每次 Agent(智能体)绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 SessionHeader.origin 已经回答了同一个问题的绝大部分。

根因在于 durable-subagent-catalog 决策把描述符事件(subagent/descriptor)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为「无索引的正确性基线」。web subagent conversations(#1569)已把「是不是 subagent」放进了 header(SessionHeader.origin),身份判定不再读日志;mode 与 label 仍然要扫。

决策

mode 与 label 由新的 subagent projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;listChildren 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 sessionProjectionCache checkpoint,取到过 seq 门的身份即定值;否则一次 persistence.inspect 整读加 registry.restore 折叠。无索引、不自建缓存、无回写。

消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。「值随查询索引落库」已整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被「算完即止」显式接受。前两条与退役理由详见考虑过的替代方案一节。

要点:

  • subagent 列表不依赖 session-query:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 ctx.sessionProjections 取值;没有 query backend 的部署照常列表。
  • 取值三级「算完即止」阶梯:live child 读 sessionProjections.snapshot()(注册表既有水位缓存,零日志读);cold child 先读可选 sessionProjectionCache.cachedSnapshot(header),values 含非 null 且过 seq 门(seq >= seedLength ?? 0)的 subagent 身份即直接用;否则一次 persistence.inspect 整读加 registry.restore({}, events, 0) 折叠;再没有就没有——不自建缓存、无回写、无索引。
  • subagent projection unit 是折叠规则唯一权威:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。
  • header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动;存量数据第一次被列表时一次 inspect 现算获得精确值,无 unknown 降级态、无迁移。

与既有记录的关系:

  • 本记录取代 durable-subagent-catalog 中列表读路径的两项设计:经 sessionQuery.traceSession 枚举,与逐 child 读取描述符事件(listEvents 加精确 readEvent 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活约定不动。属部分取代,两记录保持交叉链接。
  • session-projection RFC 的 registry 约定(ProjectionDefinitionsnapshotrestore)零改动,本记录只为其新增 subagent 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。

subagent projection unit

挂在现有 subagentTiming 旁(projection.tsprojection-types.ts),key 为 subagent

export type SubagentIdentityProjection =
  | { mode: 'one-shot'; label?: string; seq: number }
  | { mode: 'continuable'; label: string; seq: number }

declare module '@deepseek-ai/dsh-session-projection/types' {
  interface SessionProjectionMap {
    subagent: SubagentIdentityProjection | null
  }
}
  • 投影是纯身份,projection 体系不做失败通道:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是可序列化的 null 哨兵——map 条目为 SubagentIdentityProjection | null,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 listChildren 四态映射)。
  • label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 seq——它是投影内部的 own-suffix 证明)。
  • 身份携带 seq:折出该身份的 subagent/descriptor 事件 seq,两臂必有、null 哨兵无——seq >= header.seedLength ?? 0 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 seq 使 unit stateVersion 升至 2,既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。
  • 折叠规则:subagent/descriptor last-wins,与 subagentTiming 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。

枚举:subagent 自管 live-preferred 合并

listChildrenlist-children.ts)的枚举不经任何查询服务:ctx.sessions.list()ctx.get('sessionPersistence')?.list() 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实:

  • 过滤:header.origin === 'subagent' && header.parentSession === parentSessionId
  • hasChildren:同一份合并材料向下看一层——存在 origin === 'subagent'parentSession 为该 child 的直接后代。
  • activity:live 记录为 running,仅存在于持久化的为 inactive
  • 排序:createdAt 升序、再按 child id 升序(与旧约定一致)。
  • persistence 缺席退为 live-only 枚举,不报错:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。)
  • persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。

取值:三级「算完即止」阶梯

对每个枚举出的 child,mode/label 取值走三级阶梯——算完即止,不自建缓存、无回写(第三级与 apiproxy session.history 的冷读同款):

读法成本
1:live childctx.sessionProjections.snapshot(session).values.subagent零日志读——注册表既有水位缓存,同步取值
2:cold child,cache 命中可选 sessionProjectionCache.cachedSnapshot(header),values 含非 null 的 subagent 身份且 identity.seq >= header.seedLength ?? 0 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位零日志读
3:cold child,兜底persistence.inspect(id) 整读 + registry.restore({}, events, 0).snapshot.values.subagent每次列表一次整读现算
  • 错误约定:ctx.sessionProjections 未挂载是配置错误,listChildren 在枚举前无条件检查并以 SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:ctx.get('sessions')(严格全局读取,不走调用方作用域的属性代理)缺席以 SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE 失败。两码的 wire 映射有别:apiproxy 只为 PROJECTIONS_UNAVAILABLE 设专门 wire 脸,SESSION_STORE_UNAVAILABLE 走通用 internal 兜底——apiproxy 组合自身就 inject sessions,该错误在其部署不可达,专门映射违反 need 原则。SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE 已随 session-query 依赖删除。
  • cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 sessionProjections 的响亮约定相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 viewCheckpoint 引爆)静默落第三级——缓存是派生数据,其故障不产生 corrupt 判决,终审归权威重折;checkpoint 切面早于描述符的行,subagent key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。
  • per-child 隔离:单 child 的 cold 整读失败只使该行成为 unavailable diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。
  • 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 corrupt 行,不外漏新 owner 的 child。
  • 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 Config 字段。
  • 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 inspect()Session 准备阶段的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。
  • 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 CANCELLED

权威模型

  • session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 sessionProjectionCache checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(own descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)。
  • Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。
  • 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。

listChildren 行形状与消费面

SubagentListEntry 数据结构与重写前完全一致——child 与 diagnostic 两臂、kind 判别、reason 三值、child 臂的 mode/label 强约定全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身零事件解析。「没有就等待硬读取」保证阶梯对健康数据必然算得出 mode/label。

export type SubagentListEntry =
  | ({
    readonly kind: 'child'
    readonly id: SessionId
    readonly activity: 'running' | 'inactive'
    readonly hasChildren: boolean
  } & (
    | { readonly mode: 'one-shot'; readonly label?: string }
    | { readonly mode: 'continuable'; readonly label: string }
  ))
  | {
    readonly kind: 'diagnostic'
    readonly id: SessionId
    readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
  }

对每个枚举出的 child,阶梯取值结果按四态映射成行:

阶梯取值结果
快照含非 null 的 subagent 身份child 行
快照在、subagent 为 null 哨兵或 key 缺席,且 child inactivediagnostic 行,reason corrupt(定局残骸:无、损坏或版本不认识的描述符,不再细分)
快照在、subagent 为 null 哨兵或 key 缺席,且 child running行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit)
cold 整读失败diagnostic 行,reason unavailable
  • unsupported 不再被产出:类型与 wire 枚举按「数据结构保持现状」留存该成员,本记录留档其为不再产出。
  • descriptor-less 定局残骸从旧实现的 omit 归入 corrupt diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。
  • 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason corrupt——确定性数据故障,对齐旧实现 SESSION_QUERY_CORRUPT_SESSIONcorrupt 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,fold 抛错是「数据坏了」——running 的中毒 child 也出 corrupt 行而非 omit。

已知边界偏差(有意接受,随本记录留档):

  • 死于发布窗口的 fork child,seed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(NOT_RESUMABLE)。旧实现靠 seedLength 过滤将其 omit;projection unit 看不到 header,接受此残骸级偏差(subagentTiming 有同类既有暴露)。
  • own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(提供方约定本就保证恰一)。
  • live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。
  • 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child corrupt,现统一成 unavailable 行(读侧无从区分成因)。
  • 未知 parent,旧实现经 session-query 抛 not-found(「parent session … was not found」);现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。
  • rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。

消费面:wire、tool、GUI 的 diagnostic 处理全部保持原状零改动list_agents 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 sessionQuery)。行为上动的只有 apiproxy:路由段的 hasSubagentDescriptor() 扫描已删除,hasSubagentOwner 只看 header.origin——pre-#1569 的无 origin 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;subagents.historysession.history 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 inspectServable 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(history 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。

改动落点

区域文件改动
subagentprojection.ts、projection-types.ts、index.tssubagent unit 与注册
subagentlist-children.ts 及类型重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLESUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过)
host/apiproxyapi-proxy.tshasSubagentDescriptor,属主判定只看 header.originsubagents.historysession.history 同源——live 用内存事件与注册表水位快照,cold 用 inspectServable 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役
tooltool-subagent-control/list-agents.ts加载要求收窄(inject 去 sessionQuery);model-visible schema、描述与渲染零改动
wire/clientapi/subagents.ts、runtime sessions/service.ts、GUI类型、行形状与 diagnostic 处理零改动;api/subagents.ts 仅 history 的 JSDoc 措辞改为双臂
core/session、session-persistence、session-projection(-cache)、session-query(-sqlite)零改动

考虑过的替代方案

mode/label 进 SessionHeader。 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是「第一次列表一次 inspect 现算」,不碰持久格式。

projection-cache 阶梯(cachedSnapshot ?? coldSnapshot 加 fail-soft 写回)。 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。

给 persistence 加有界读原语抢救存量。 为一次性问题新开 persistence 原语;被读时 inspect 整读取代——存量第一次被列表时的整读就是取值本身。

list 行 mode/label 可选化。 健康数据必然可算;可选化只是把垃圾数据的处理复杂度外溢给全部消费方——每个消费面都要长出过滤分支和 unknown 展示态。强约定加算不出即 omit 更干净。

彻底删除 diagnostic 行。 删除把库损坏的可见性外溢为行静默消失,wire/tool/GUI 反要各自承担约定与快照变更;而保留只需列表侧按投影值缺席与 activity 派生分类,零成本。库里的坏、死子会话必须可见是 diagnostic 存在的原始动机,保留后消费面整体零改动。

registry 计算失败通道(per-unit 容错加 failures 附加字段)。 为把损坏、版本不认识报告给消费方,由 registry 捕获 unit 异常并在 snapshot 旁附 per-key 失败态。被否:failure 不是值,也不必是通道——unit 永不抛错,缺席本身就是信号,「大不了算出来没有」,如何呈现是消费方要考虑的事。一个独立观察:vendor cordis 的 emitvendor/cordis/src/events.ts)对 listener 抛错零捕获,投影驱动挂在 session/event 上时 unit 异常会沿 emit 逃逸——这加重了「unit 永不抛错」纪律的分量,但 emit 容错的修复不属于本记录范围。

值随 query 索引 preparation 落库。 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志:projectionsFor 批量读面、行值随 (key → stateVersion) 注册集存储的失效对账与 SCHEMA bump。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。SESSION_QUERY_PROJECTIONS_UNAVAILABLE 随读面一并删除。

subagent 手工 parse 加进程 memo 加创建播种。 为摘除 session-query 依赖,由 subagent 包自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被已交付的阶梯取代:live 走 sessionProjections 水位缓存、cold 走 registry.restore,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。

session-query 输出面 DeepReadonly(读路径改造实验)。 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。

验证

packages/subagent/subagent/tests/list-children.spec.ts 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE;live child 全程零 inspect、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 corrupt;冷读失败映射 unavailable 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 hasChildrencreatedAt→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 CANCELLED;空列表与稳定错误码。敌意 unit 双路探针(apply 惰性置毒、view 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 corrupt 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 inspect、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(it.each)降级为 corrupttool-subagent-control 的 list-agents 测试随加载要求收窄更新;optional-session-query.spec.ts 随依赖消失删除;既有无密钥快照(subagent-list-agents 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 subagent-diagnostic(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 corrupt 行等模型可见变化。

后果

  • live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 inspect 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。
  • subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE 消失,list_agents 插件加载不再要求 sessionQuery
  • 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
  • per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。
  • 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 corruptunavailable、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。
  • pre-#1569 的无 origin 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。

相关