refactor(session): rename the seed boundary to end-seed

This commit is contained in:
Hypatia May
2026-07-30 15:32:06 +08:00
parent e8f0934bc2
commit 8527137230
44 changed files with 228 additions and 224 deletions

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/host/apiproxy/README.md
README.md: c745dd6fb142b5b5a50b4593e3ce0223e2be6728
README.zh.md: 7ddd7a37f7dd45f683a93601637c75b3f1bd8fd2
README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928

View File

@@ -44,4 +44,4 @@ None; this package neither assembles nor sends a provider request.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).

View File

@@ -44,4 +44,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -165,7 +165,7 @@ function sessionBlank(session: Session): boolean {
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
// Excludes the inherited-history boundary: a resumed-but-untouched session
// Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,

View File

@@ -134,7 +134,7 @@ export type QueueAction =
export interface SessionSummary {
sessionId: SessionId
/**
* Last activity. Attached: the last non-`session/inherited` event, since a
* Last activity. Attached: the last non-`session/end-seed` event, since a
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
* with no per-session file (README Known Limitations covers the skew).
*/

View File

@@ -78,7 +78,7 @@ describe('sessions.list cold merge', () => {
})
})
describe('attached updatedAt excludes the inherited-history boundary', () => {
describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -97,7 +97,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
})
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
const boundary = resumed.events.at(-1)
expect(boundary?.type).toBe('session/inherited')
expect(boundary?.type).toBe('session/end-seed')
expect(boundary?.time).toBeGreaterThan(worked)
const listed = await api.sessions.list(request({}))
@@ -105,7 +105,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
// Real work above the boundary does move it.
// Real work appended after end-seed does move it.
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')