refactor(client): subagent lineage 遍历收敛为共享 subagent-lineage 模块

rootAncestor / countSubagentDescendants / treeSessionIds 三份「沿
subagent-origin 链上行 + seen 集合防环 fail-soft」实现收敛到新模块
src/client/subagent-lineage.ts:核心走链是单个私有生成器
subagentOriginChain()(seen 防环、只在节点为 subagent 且有 parent 时
前行),三个公开函数迁入并基于它重写;isSideThreadSummary 一并迁入
(countSubagentDescendants 依赖它,迁入可避免 detect↔lineage 循环
依赖),subagent-detect.ts / subagent-jobs.ts re-export 全部迁出
符号,既有 import 调用点零变化。

语义逐字保留的等价性论证:
- rootAncestor:链为空返回 start.id(原循环条件不满足即返回
  current?.id);链非空返回 byId[末节点.parentId]?.id ?? sessionId
  (原循环退出时 current 恰为该行,断链/行缺失回退 sessionId 不变)。
- countSubagentDescendants:生成器在 seen.add 后 yield、消费方判
  parentId === sessionId 即 break——与原循环体内判断顺序一致;
  descendant 的 running 判定、side-thread 过滤位置不变。
- treeSessionIds:根行缺失(byId[rootId] 无行)时任何上行都不可能
  「访问到」根(原实现只匹配真实访问过的行),故提前返回空集;
  根行自身(非 subagent)经 summary.id === rootId 特判入集;其余
  行经链节点 parentId === rootId 判定,等价于原「上行走到 id ===
  rootId 的行」——两种表述指向同一行(链上节点的 next 行)。

directSubagentCount 不迁移:它是对直接子行的单层过滤,没有上行
walk,无共享逻辑(背景侦查将其归入 walk 家族与实际代码不符)。
collectBranchIds 遍历的是 catalog 结构而非 session 链,保持不动。
This commit is contained in:
Menghuan1918
2026-09-03 12:05:41 +08:00
parent 857646ea66
commit 69cc8014a7
3 changed files with 144 additions and 98 deletions
+8 -67
View File
@@ -9,23 +9,20 @@
* - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage
* totals (mirror of the official `indexSubagentDescendants` over the
* plugin's own summary rows).
*
* The lineage walks themselves ({@link isSideThreadSummary}, {@link
* rootAncestor}, {@link countSubagentDescendants}) live in
* ./subagent-lineage.ts — the single shared walk implementation — and are
* re-exported below for their established import sites.
*/
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentCatalog,
} from '../context-types.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import { countSubagentDescendants, isSideThreadSummary, rootAncestor } from './subagent-lineage.ts'
/**
* Side Chat threads ride the subagent origin (main-list hiding + the RPC
* ownership fence) but they are NOT subagent topology: they carry the
* durable 'Side: ' label and live as sidebar tabs. Excluding them here
* keeps the auto-open trigger and the Subagent page counts clean.
*/
export function isSideThreadSummary(summary: SidebarSessionSummary): boolean {
return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)
}
export { countSubagentDescendants, isSideThreadSummary, rootAncestor }
export type { SubagentDescendantTotals } from './subagent-lineage.ts'
/** Count the direct subagent children of one session (durable `origin` rows). */
export function directSubagentCount(
@@ -40,28 +37,6 @@ export function directSubagentCount(
return count
}
/**
* The main agent of the current session's tree: walk the durable parent
* chain upward until the first non-subagent session. The Subagent page shows
* THIS root's full topology regardless of how deep the current selection is
* (a session whose row is still hydrating, or a broken chain, degrades to
* the session itself).
*/
export function rootAncestor(
byId: SidebarSessionList['byId'],
sessionId: string | undefined,
): string | undefined {
if (sessionId === undefined) return undefined
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = byId[sessionId]
while (current !== undefined && current.origin === 'subagent'
&& current.parentId !== undefined && !seen.has(current.id)) {
seen.add(current.id)
current = byId[current.parentId]
}
return current?.id ?? sessionId
}
/**
* Collect every catalog branch (an entry with `hasChildren`) reachable from
* the root — the set of catalogs the always-expanded topology consumes.
@@ -101,37 +76,3 @@ export function detectNewDirectSubagent(
return directSubagentCount(prev.byId, sessionId) === 0
&& directSubagentCount(next.byId, sessionId) > 0
}
/** Descendant totals of one session through an uninterrupted subagent-origin chain. */
export interface SubagentDescendantTotals {
count: number
runningCount: number
}
/**
* Index every subagent descendant under each ancestor it reaches through an
* uninterrupted subagent-origin chain (same semantics as the official
* `indexSubagentDescendants`; cycles fail soft).
*/
export function countSubagentDescendants(
byId: SidebarSessionList['byId'],
sessionId: string,
): SubagentDescendantTotals {
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
for (const descendant of Object.values(byId)) {
if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined
&& !seen.has(current.id)) {
seen.add(current.id)
if (current.parentId === sessionId) {
totals.count += 1
if (descendant.running === true) totals.runningCount += 1
break
}
current = byId[current.parentId]
}
}
return totals
}
+5 -31
View File
@@ -8,11 +8,11 @@
*/
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarJobStatus,
SidebarJobView,
} from '../context-types.ts'
import type { CopyKey } from './locales.ts'
import { treeSessionIds } from './subagent-lineage.ts'
/** One row of the jobs section: the job plus its owning session's title. */
export interface TreeJob {
@@ -26,36 +26,10 @@ export function isJobLive(job: SidebarJobView): boolean {
return job.status === 'running' || job.status === 'stopping'
}
/**
* Every session id of the topology tree rooted at `rootId` (the root plus
* each session whose uninterrupted subagent-origin chain reaches it — same
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
* Sessions outside the tree (orphans, other trees) are excluded, so the
* jobs section never shows foreign work.
*/
export function treeSessionIds(
byId: SidebarSessionList['byId'],
rootId: string | undefined,
): Set<string> {
const ids = new Set<string>()
if (rootId === undefined) return ids
for (const summary of Object.values(byId)) {
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = summary
let reachesRoot = false
while (current !== undefined && !seen.has(current.id)) {
seen.add(current.id)
if (current.id === rootId) {
reachesRoot = true
break
}
if (current.origin !== 'subagent' || current.parentId === undefined) break
current = byId[current.parentId]
}
if (reachesRoot) ids.add(summary.id)
}
return ids
}
// The lineage walk itself lives in ./subagent-lineage.ts (the single shared
// subagent-origin chain implementation); re-exported for the established
// import sites of this module.
export { treeSessionIds }
/**
* Whether a NEW background job appeared for one session between two
+131
View File
@@ -0,0 +1,131 @@
/**
* The shared subagent-lineage walks over the sessions list feed (structural
* mirror world — no runtime imports). Every public walk here answers a
* topology question by following the DURABLE subagent-origin chain upward
* (child → parent → …); one private generator ({@link subagentOriginChain})
* is the single implementation of that walk, seen-set guarded so cycles fail
* soft (the walk just stops; nothing throws).
*
* subagent-detect.ts and subagent-jobs.ts re-export these for their
* established import sites — the auto-activation triggers and the Subagent
* page both consume them through the original module paths.
*/
import type { SidebarSessionList, SidebarSessionSummary } from '../context-types.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
/**
* Side Chat threads ride the subagent origin (main-list hiding + the RPC
* ownership fence) but they are NOT subagent topology: they carry the
* durable 'Side: ' label and live as sidebar tabs. Excluding them here
* keeps the auto-open trigger and the Subagent page counts clean.
*/
export function isSideThreadSummary(summary: SidebarSessionSummary): boolean {
return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)
}
/**
* Yield `start`'s uninterrupted subagent-origin chain upward: each summary
* while it is a subagent with a known parent, then its parent row, and so
* on. A revisited id ends the walk (cycles fail soft); the row that BREAKS
* the chain — the first non-subagent ancestor, or a parent id with no row —
* is deliberately NOT yielded: callers observe it through where the walk
* stopped (the row after the last yielded one is `byId[last.parentId]`).
*/
function* subagentOriginChain(
byId: SidebarSessionList['byId'],
start: SidebarSessionSummary,
): Generator<SidebarSessionSummary> {
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = start
while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) {
seen.add(current.id)
yield current
current = byId[current.parentId]
}
}
/**
* The main agent of the current session's tree: walk the durable parent
* chain upward until the first non-subagent session. The Subagent page shows
* THIS root's full topology regardless of how deep the current selection is
* (a session whose row is still hydrating, or a broken chain, degrades to
* the session itself).
*/
export function rootAncestor(
byId: SidebarSessionList['byId'],
sessionId: string | undefined,
): string | undefined {
if (sessionId === undefined) return undefined
const start = byId[sessionId]
// A hydrating (or unknown) row degrades to the session itself.
if (start === undefined) return sessionId
let last: SidebarSessionSummary | undefined
for (const node of subagentOriginChain(byId, start)) last = node
if (last === undefined) return start.id
// The walk stopped one row past the last chain node: that parent row is
// the root (still missing from the feed → the session itself). Every
// yielded node carries a parent id (the generator's walk condition).
return byId[last.parentId!]?.id ?? sessionId
}
/** Descendant totals of one session through an uninterrupted subagent-origin chain. */
export interface SubagentDescendantTotals {
count: number
runningCount: number
}
/**
* Index every subagent descendant under each ancestor it reaches through an
* uninterrupted subagent-origin chain (same semantics as the official
* `indexSubagentDescendants`; cycles fail soft).
*/
export function countSubagentDescendants(
byId: SidebarSessionList['byId'],
sessionId: string,
): SubagentDescendantTotals {
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
for (const descendant of Object.values(byId)) {
if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue
for (const node of subagentOriginChain(byId, descendant)) {
if (node.parentId === sessionId) {
totals.count += 1
if (descendant.running === true) totals.runningCount += 1
break
}
}
}
return totals
}
/**
* Every session id of the topology tree rooted at `rootId` (the root plus
* each session whose uninterrupted subagent-origin chain reaches it — same
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
* Sessions outside the tree (orphans, other trees) are excluded, so the
* jobs section never shows foreign work.
*/
export function treeSessionIds(
byId: SidebarSessionList['byId'],
rootId: string | undefined,
): Set<string> {
const ids = new Set<string>()
// A root with no row in the feed cannot be reached by walking: only real
// rows anchor tree membership (a parent pointer naming a hydrating root
// does not pull the orphaned branch into the tree).
if (rootId === undefined || byId[rootId] === undefined) return ids
for (const summary of Object.values(byId)) {
// The root row itself belongs to its own tree even though it is not a
// subagent (it is usually the main agent).
if (summary.id === rootId) {
ids.add(summary.id)
continue
}
for (const node of subagentOriginChain(byId, summary)) {
if (node.parentId === rootId) {
ids.add(summary.id)
break
}
}
}
return ids
}