feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand

Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.

- Extract the `Branded<B>` primitive into a new standalone type-only package
  `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
  dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
  dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
  dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
  generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
  the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
  the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
  SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
  that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
  agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
  at the config boundary and the inner create()/resume casts disappear (only the
  genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
  Map keys and public params/exports (SessionStore, AgentRegistry + factory
  options, the ACP session-id surface + ToolPresenter CallId map, the
  persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
  the Branded type-equiv at dsh-brand, fix stale param types in the session/
  agent/bash READMEs, regenerate the cordis catalog + module graph.

Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
This commit is contained in:
Tianyi Cui
2026-06-21 07:17:25 +08:00
parent 24168aee70
commit d6a2ab30c8
75 changed files with 644 additions and 445 deletions

View File

@@ -274,8 +274,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
const a = ctx.sessions.create('sa')
const b = ctx.sessions.create('sb')
const a = ctx.sessions.create(SessionId('sa'))
const b = ctx.sessions.create(SessionId('sb'))
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } })
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -451,7 +451,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
// Session A materializes a log under id "reuse".
const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
const a = inner.sessions.create('reuse', { meta: { cwd: '/a' } })
const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
for (const e of oneTurnLog()) a.append(e.type, e.data)
}, { inject: ['sessions'] }))
// Drain A, then dispose ITS fiber (the live session A is gone) while the
@@ -466,7 +466,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let b!: Session
await ctx.plugin(Object.assign((inner: Context) => {
b = inner.sessions.create('reuse', { meta: { cwd: '/a' } })
b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
})
@@ -493,7 +493,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let b!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
b = inner.sessions.create('x') // no cwd
b = inner.sessions.create(SessionId('x')) // no cwd
}, { inject: ['sessions'] }))
await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/)
@@ -521,7 +521,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }]
let bad!: Session
await ctx.plugin(Object.assign((inner: Context) => {
bad = inner.sessions.create('divergent', { seed: tampered, meta: { cwd: '/a' } })
bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
})
@@ -529,7 +529,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
it('a second live session reusing a bound id is rejected', async () => {
// A live session materializes and owns the id.
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
const a = inner.sessions.create('bound', { meta: { cwd: '/a' } })
const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}, { inject: ['sessions'] }))
@@ -539,7 +539,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let second!: Session
await ctx.plugin(Object.assign((inner: Context) => {
second = inner.sessions.create('bound', { meta: { cwd: '/a' } })
second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(second))
.rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
@@ -580,7 +580,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let s!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
s = inner.sessions.create('exists-fault', { meta: { cwd } })
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
@@ -645,7 +645,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
const session = ctx2.sessions.create('flush-fail')
const session = ctx2.sessions.create(SessionId('flush-fail'))
// A full turn lands in the write-behind buffer.
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -689,7 +689,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
})
it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
const session = ctx.sessions.create('reject-bad')
const session = ctx.sessions.create(SessionId('reject-bad'))
// Serializability is enforced at the source: Session.append throws on a
// BigInt-bearing event BEFORE it enters session.events, so the durable log
// can never diverge from the live log. The throw surfaces at the caller's

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import { openDatabase, scanRows, type EventRow } from '../src/schema.ts'
@@ -352,7 +352,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const path = await freshDbPath()
// Instance 1 materializes a session and disposes.
const b1 = await backend(path)
const s1 = b1.ctx.sessions.create('hmr-collide')
const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
for (const e of oneTurnLog()) s1.append(e.type, e.data)
await b1.ctx.parallel('session/flush', s1)
await b1.dispose()
@@ -363,7 +363,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await ctx.plugin(SessionStore)
let session!: Session
await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('hmr-collide')
session = inner.sessions.create(SessionId('hmr-collide'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
await ctx.plugin(SessionPersistenceSqlite, { path })

View File

@@ -157,14 +157,14 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
*/
export class PersistenceCoordinator<TornMarker = unknown> {
/** Backend bookkeeping keyed by session id (NOT the live Session object). */
private states = new Map<string, SessionState>()
private states = new Map<SessionId, SessionState>()
/** Write-behind buffers keyed by the live Session (write path). */
private buffers = new Map<Session, SessionEvent[]>()
/**
* Per-session serialization: every operation chains onto the prior one for the
* same id, so writes for one session never interleave. Keyed by session id.
*/
private chains = new Map<string, Promise<unknown>>()
private chains = new Map<SessionId, Promise<unknown>>()
/**
* Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not
* its id: a disposed fiber's session can be replaced by a different live

View File

@@ -85,7 +85,7 @@ async function liveSessionInFiber(
): Promise<Session> {
let session!: Session
await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(id, cwd !== undefined ? { meta: { cwd } } : undefined)
session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined)
}, { inject: ['sessions'] }))
return session
}
@@ -110,7 +110,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create('live', { meta: { cwd: WORK } })
const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } })
send(session, oneTurnLog())
await ctx.parallel('session/flush', session)
@@ -127,7 +127,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create('mutate', { meta: { cwd: WORK } })
const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
// Mutate the live event object AFTER it was buffered by session/event.
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
@@ -177,7 +177,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const seed = oneTurnLog()
// A fork: a brand-new id whose seed came from elsewhere.
const forked = ctx.sessions.create('forked', { seed, meta: { cwd: WORK } })
const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(loaded.events).toEqual(seed)
@@ -196,7 +196,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const first = await freshCtx(fix)
try {
// First lifecycle: persist a session through the store.
const s1 = first.ctx.sessions.create('resumed', { meta: { cwd: WORK } })
const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
} finally {
@@ -209,7 +209,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const second = await freshCtx(fix)
try {
const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
const s2 = second.ctx.sessions.create('resumed', { seed: loaded.events, meta: { cwd: WORK } })
const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt
s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
@@ -231,7 +231,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const ctx = new Context()
await ctx.plugin(SessionStore)
// A session exists BEFORE the persistence plugin is applied.
const session = ctx.sessions.create('pre-existing', { meta: { cwd: WORK } })
const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -371,7 +371,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const fix = await makeFixture()
const first = await freshCtx(fix)
try {
const s1 = first.ctx.sessions.create('collide', { meta: { cwd: WORK } })
const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
} finally {
@@ -383,7 +383,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// exists. The rejection surfaces via the init promise (flush awaits it).
const second = await freshCtx(fix)
try {
const s2 = second.ctx.sessions.create('collide', { meta: { cwd: WORK } })
const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await expect(inits(second.ctx.sessionPersistence).get(s2))
.rejects.toThrow(/already has a persisted log|id collision/)
@@ -401,14 +401,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// never materialized. A new live session reusing the id must reclaim it.
let firstSession!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
firstSession = inner.sessions.create('abandoned', { meta: { cwd: WORK } })
firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state
await firstFiber.dispose() // disposed before any append → never materialized
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create('abandoned', { meta: { cwd: WORK } })
reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined()
reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -428,7 +428,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create('buffered', { meta: { cwd: WORK } })
first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await inits(ctx.sessionPersistence).get(first)
// Append a turn but do NOT flush — events sit in the write-behind buffer.
@@ -438,7 +438,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create('buffered', { meta: { cwd: WORK } })
reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/)
} finally {
@@ -451,7 +451,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create('idem', { meta: { cwd: WORK } })
const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
@@ -476,7 +476,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.sessionPersistence.create(meta('lazy-claim', WORK))
// A live session with that id arrives and claims it (cursor 0 matches
// trivially), persisting its seed.
const live = ctx.sessions.create('lazy-claim', { seed: oneTurnLog(), meta: { cwd: WORK } })
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined()
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
@@ -500,7 +500,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// seq 0..cursor-1 events would otherwise be filtered as already-persisted.
let fresh!: Session
await ctx.plugin(Object.assign((inner: Context) => {
fresh = inner.sessions.create('preview', { meta: { cwd: WORK } })
fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await expect(inits(ctx.sessionPersistence).get(fresh))
.rejects.toThrow(/do not match this live session|already has a persisted log|id collision/)
@@ -521,7 +521,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// A live session SEEDED with the loaded log PLUS a new turn claims the
// ownerless state and persists only the suffix.
const cont = ctx.sessions.create('claim', { seed: [
const cont = ctx.sessions.create(SessionId('claim'), { seed: [
...events,
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
@@ -545,7 +545,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// A live session reusing the id but at cwd WORK must NOT claim it — the
// cwd scope is the fence (without it, WORK events would append under the
// OTHER header). Rejected as a collision.
const live = ctx.sessions.create('wrong-cwd-claim', { seed: oneTurnLog(), meta: { cwd: WORK } })
const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
} finally {
await fiber.dispose()
@@ -563,7 +563,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load'))
// A live session whose SEED matches the loaded prefix but whose cwd is
// WORK must still be rejected — the cwd guard runs before the seed check.
const live = ctx.sessions.create('wrong-cwd-load', { seed: events, meta: { cwd: WORK } })
const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } })
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
} finally {
await fiber.dispose()
@@ -579,7 +579,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.sessionPersistence.create(meta('no-cwd-state'))
// A live session reusing the id but WITH cwd WORK is a cwd mismatch
// (undefined vs WORK) and must be rejected.
const live = ctx.sessions.create('no-cwd-state', { seed: oneTurnLog(), meta: { cwd: WORK } })
const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
} finally {
await fiber.dispose()
@@ -703,7 +703,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Append directly to a live session and flush IMMEDIATELY, before the
// async onCreated init has necessarily set state (exercises the
// state-undefined cursor path).
const session = ctx.sessions.create('flush-nostate', { meta: { cwd: WORK } })
const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)