bound inactive subagent timing to projection cut

This commit is contained in:
kingwl
2026-08-03 11:40:33 +08:00
parent c831c99981
commit 0b0de64769
11 changed files with 66 additions and 38 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
2026-07-27-web-subagent-conversations.md: 859c6c5c17e830ab55c8513d56741966655eaf7a
2026-07-27-web-subagent-conversations.zh.md: 05d5c0f1d59b0bdebdecb33dc360e937af44d7b6
2026-07-27-web-subagent-conversations.md: 09a444dc6c391f3abdfc5bfe0f3367d4bda430c3
2026-07-27-web-subagent-conversations.zh.md: 87929e8b35212092587ca4566bd10a1242c84e30
@@ -41,7 +41,7 @@ The header action is absent only when a complete empty direct-catalog response a
`running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start``turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. The duration does not imply a durable outcome.
Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start``turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. The duration does not imply a durable outcome.
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
@@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
`running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start``turn/end` 时段,并携带当前轮次`activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。
健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start``turn/end` 时段,并携带未结束轮次同一切面`active.since``active.through` 边界。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单`active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRightArrowLeft 展开与折叠、线性 ArrowUpArrowDown 导航、HomeEnd、Escape 以及焦点恢复。
@@ -69,9 +69,11 @@ function activityDuration(
const timing: SessionProjectionMap['subagentTiming'] | undefined
= summary.projectionValues?.subagentTiming
if (timing === undefined) return undefined
if (timing.activeSince === undefined) return timing.settledMs
const end = activity === 'running' ? now : summary.updatedAt
return timing.settledMs + Math.max(0, end - timing.activeSince)
if (timing.active === undefined) return timing.settledMs
const end = activity === 'running'
? now
: timing.active.through
return timing.settledMs + Math.max(0, end - timing.active.since)
}
/** Format a non-negative duration to seconds without dropping larger units. */
@@ -245,9 +245,9 @@ describe('SubagentCatalogAction', () => {
vi.useFakeTimers()
vi.setSystemTime(now)
const rows = [
['running', 'running', 65_000, now - 5_000, now],
['finished', 'inactive', 3_723_000, undefined, now - 60_000],
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000],
['running', 'running', 65_000, now - 5_000, now - 1_000, now],
['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000],
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000],
] as const
const entries = rows.map(([id, activity]) => ({
kind: 'child' as const,
@@ -257,7 +257,9 @@ describe('SubagentCatalogAction', () => {
activity,
hasChildren: false,
}))
const summaries = Object.fromEntries(rows.map(([id, activity, settledMs, activeSince, updatedAt]) => {
const summaries = Object.fromEntries(rows.map(([
id, activity, settledMs, activeSince, activeThrough, updatedAt,
]) => {
const childId = id as SessionId
return [id, {
...summary(childId, updatedAt),
@@ -267,7 +269,9 @@ describe('SubagentCatalogAction', () => {
projectionValues: {
subagentTiming: {
settledMs,
...(activeSince === undefined ? {} : { activeSince }),
...(activeSince === undefined || activeThrough === undefined
? {}
: { active: { since: activeSince, through: activeThrough } }),
},
},
}]
+2 -2
View File
@@ -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: ec4af55bcd9374b1abb55d7bb098eef568449684
README.zh.md: 8323853ff0de2a15da6475fc1433a68f0074ee93
README.md: e54f0b98ec3649cec428a47026e6657a9749608b
README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4
+1 -1
View File
@@ -92,7 +92,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start``turn/end` active time and retains `activeSince` for an open turn. Only descriptors and turn boundaries change the value, so token chunks do not create timing updates.
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start``turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata.
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
+1 -1
View File
@@ -92,7 +92,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
`ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start``turn/end` 活跃时间,并为未结束的轮次保留 `activeSince`。只有描述符和轮次边界会改变该值,因此 token 分片不会产生计时更新
`ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start``turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since``active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
@@ -8,8 +8,13 @@
export interface SubagentTimingProjection {
/** Milliseconds accumulated across completed turns after the child's own descriptor. */
settledMs: number
/** Start of the currently open turn, when one has not reached `turn/end`. */
activeSince?: number
/** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */
active?: {
/** Start of the open turn. */
since: number
/** Latest event time folded into this projection cut. */
through: number
}
}
declare module '@deepseek-ai/dsh-session-projection/types' {
+32 -18
View File
@@ -8,16 +8,25 @@ import { z } from 'zod'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { SubagentTimingProjection } from './projection-types.ts'
interface TimingState extends SubagentTimingProjection {
interface TimingState {
/** Milliseconds accumulated across completed post-descriptor turns. */
settledMs: number
/** Current open interval kept paired inside the fold. */
active?: { since: number; through: number }
/** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
pendingTurnStart?: number
/** Whether the fold has crossed a descriptor in this logical log. */
descriptorSeen: boolean
}
// Cast for the optional values: under exactOptionalPropertyTypes zod infers
// `number | undefined` where the interface declares absent-or-number fields.
const projectionSchema = z.object({
settledMs: z.number().int().nonnegative(),
activeSince: z.number().int().nonnegative().optional(),
active: z.object({
since: z.number().int().nonnegative(),
through: z.number().int().nonnegative(),
}).strict().optional(),
}).strict() as unknown as z.ZodType<SubagentTimingProjection>
/**
@@ -36,33 +45,38 @@ ProjectionDefinition<'subagentTiming', TimingState> = {
apply: (state, event) => {
if (event.type === 'turn/start') {
return state.descriptorSeen
? { ...state, activeSince: event.time }
? { ...state, active: { since: event.time, through: event.time } }
: { ...state, pendingTurnStart: event.time }
}
if (event.type === 'subagent/descriptor') {
const activeSince = state.activeSince ?? state.pendingTurnStart
const activeSince = state.active?.since ?? state.pendingTurnStart
return {
descriptorSeen: true,
settledMs: 0,
...(activeSince === undefined ? {} : { activeSince }),
...(activeSince === undefined
? {}
: { active: { since: activeSince, through: event.time } }),
}
}
if (event.type !== 'turn/end') return state
if (!state.descriptorSeen) {
if (state.pendingTurnStart === undefined) return state
const { pendingTurnStart: _closed, ...next } = state
return next
}
if (state.activeSince === undefined) return state
const { activeSince, ...rest } = state
return {
...rest,
settledMs: state.settledMs + Math.max(0, event.time - activeSince),
if (event.type === 'turn/end') {
if (!state.descriptorSeen) {
if (state.pendingTurnStart === undefined) return state
const { pendingTurnStart: _closed, ...next } = state
return next
}
if (state.active === undefined) return state
const { active, ...rest } = state
return {
...rest,
settledMs: state.settledMs + Math.max(0, event.time - active.since),
}
}
if (state.active === undefined) return state
return { ...state, active: { ...state.active, through: event.time } }
},
view: state => ({
settledMs: state.settledMs,
...(state.activeSince === undefined ? {} : { activeSince: state.activeSince }),
...(state.active === undefined ? {} : { active: state.active }),
}),
stateVersion: 1,
stateVersion: 2,
}
@@ -21,10 +21,13 @@ describe('subagent timing projection', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentService)
const serviceFiber = await ctx.plugin(SubagentService)
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
.toEqual({ settledMs: 0 })
await serviceFiber.dispose()
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
.toBeUndefined()
})
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
@@ -47,7 +50,7 @@ describe('subagent timing projection', () => {
event('turn/end', 2, 900),
event('turn/start', 3, 2_000),
event('assistant/chunk', 4, 2_500),
])).toEqual({ settledMs: 0, activeSince: 2_000 })
])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } })
})
it('ignores completed pre-descriptor turns and unrelated events', () => {