fix: review follow-ups for the session archive set

- WorkspaceRegistry.archiveSession no longer wraps persistence-listing
  failures as WorkspaceUnknownSessionError: only a definite miss (live
  lookup, header index, then a fresh list) maps to session-not-found;
  storage faults propagate as internal errors, with a negative test.
- The archived-current sweep moves from the unary path into the
  projection: any install path (local echo, another tab's frame, a
  reconnect baseline) clears a selection that landed in the archive set.
- An archive set installed while workspace.list is in flight supersedes
  the stale baseline's set instead of being rolled back by it.
- The workspace-management e2e anchors the archived row by its session
  actions button and asserts the single-stray fixture assumption loudly.
- Drop the stale touchSession rows from the workspace READMEs (the
  method was removed with its Agent Note).
This commit is contained in:
imccyu
2026-07-31 03:44:35 +08:00
committed by imccyu
parent c00f0dccda
commit d3e8f17a54
15 changed files with 103 additions and 26 deletions
+21 -8
View File
@@ -49,14 +49,16 @@ export class WorkspaceNameConflictError extends Error {
}
}
/** An archiveSession request named a session neither live nor in session persistence. */
/**
* An archiveSession request named a session neither live nor in session
* persistence — a definite miss only; storage faults propagate as themselves.
*/
export class WorkspaceUnknownSessionError extends Error {
/**
* @param sessionId - The unknown session id.
* @param options - Standard error options (the header-read failure as `cause`).
*/
constructor(readonly sessionId: SessionId, options?: ErrorOptions) {
super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`, options)
constructor(readonly sessionId: SessionId) {
super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`)
this.name = 'WorkspaceUnknownSessionError'
}
}
@@ -215,16 +217,27 @@ export class WorkspaceRegistry extends Service {
// The chain slot serializes against every other registry write, so this
// check-then-write pair cannot interleave with another archive.
if (this.requireState().archivedSessionIds.includes(sessionId)) return
try {
await this.readSessionHeader(sessionId)
} catch (error) {
throw new WorkspaceUnknownSessionError(sessionId, { cause: error })
if (!(await this.sessionKnown(sessionId))) {
throw new WorkspaceUnknownSessionError(sessionId)
}
const state = this.requireState()
await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] })
})
}
/**
* Whether a session is live, header-indexed, or present in a fresh
* persistence listing. Only a definite miss returns false — a failing
* `sessionPersistence.list()` propagates so storage faults never
* masquerade as an unknown session.
*/
private async sessionKnown(id: SessionId): Promise<boolean> {
if (this.ctx.get('sessions')?.get(id) !== undefined) return true
if (this.headers.has(id)) return true
await this.indexHeaders(await this.ctx.sessionPersistence.list())
return this.headers.has(id)
}
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned