|
|
@@ -504,10 +504,10 @@ function historyPage(
|
|
|
* registry). An absent registry means the deployment has no projection seam:
|
|
|
* the whole block is absent and clients treat every key as capability-absent.
|
|
|
*/
|
|
|
-function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
|
|
|
+function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
|
|
|
const registry = ctx.get('sessionProjections')
|
|
|
if (registry === undefined) return undefined
|
|
|
- return registry.snapshot(agent.session)
|
|
|
+ return registry.snapshot(session)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -639,6 +639,13 @@ async function catalogChild(
|
|
|
*/
|
|
|
class SessionNotFound extends Error {}
|
|
|
|
|
|
+/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */
|
|
|
+class SubagentSessionOwnership extends Error {
|
|
|
+ constructor(readonly sessionId: SessionId) {
|
|
|
+ super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/** Requested identity already belongs to a session with another project cwd. */
|
|
|
class SessionCwdConflict extends Error {
|
|
|
constructor(
|
|
|
@@ -1008,28 +1015,70 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
})
|
|
|
}
|
|
|
|
|
|
+ /** Whether the session's own suffix carries the durable subagent discriminator. */
|
|
|
+ function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
|
|
|
+ const ownStart = session.header.seedLength ?? 0
|
|
|
+ return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor')
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
- * Gate the cold path on the store: an id absent from it, or naming a legacy
|
|
|
- * log without a cwd (pre-release stance: not served, no compatibility), is
|
|
|
- * not-found before any resume is attempted. With the gate passed, a later
|
|
|
- * resume failure is genuinely internal. No persistence configured skips the
|
|
|
- * gate — resume itself then fails loud with its own diagnostic.
|
|
|
+ * Generic Host interaction cannot claim a durably classified subagent or an
|
|
|
+ * Agent created through its live parent. The runtime-owner arm also covers
|
|
|
+ * descriptor-less child publication windows and older stored headers.
|
|
|
*/
|
|
|
- async function assertServable(sessionId: SessionId): Promise<void> {
|
|
|
+ function hasSubagentOwner(
|
|
|
+ session: Pick<Session, 'events' | 'header'>,
|
|
|
+ agent: Agent | undefined,
|
|
|
+ ): boolean {
|
|
|
+ if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
|
|
|
+ const parentId = session.header.parentSession
|
|
|
+ if (parentId === undefined || agent === undefined) return false
|
|
|
+ const parent = ctx.agents.get(parentId)
|
|
|
+ return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Stable generic-Host error for an identity reserved to subagent routing. */
|
|
|
+ function subagentOwnershipError(sessionId: SessionId): RpcError {
|
|
|
+ return {
|
|
|
+ code: 'agent-busy',
|
|
|
+ message: `session "${sessionId}" is owned by subagent routing`,
|
|
|
+ details: { reason: 'use subagent delivery for this child session' },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Inspect one cold served session without repairing, resuming, or publishing it. */
|
|
|
+ async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
|
|
const persistence = ctx.get('sessionPersistence')
|
|
|
- if (persistence === undefined) return
|
|
|
+ if (persistence === undefined) {
|
|
|
+ throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
|
|
+ }
|
|
|
const meta = (await persistence.list()).find(m => m.id === sessionId)
|
|
|
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
|
|
|
+ const inspected = await persistence.inspect(sessionId)
|
|
|
+ if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
|
|
|
+ return inspected
|
|
|
}
|
|
|
|
|
|
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
|
|
|
+ const attached = ctx.sessions.get(sessionId)
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
+ if (attached !== undefined && hasSubagentOwner(attached, live)) {
|
|
|
+ return { error: subagentOwnershipError(sessionId) }
|
|
|
+ }
|
|
|
if (live !== undefined) return { agent: live }
|
|
|
let resume = resumes.get(sessionId)
|
|
|
if (resume === undefined) {
|
|
|
resume = (async () => {
|
|
|
try {
|
|
|
- await assertServable(sessionId)
|
|
|
+ const inspected = await inspectServable(sessionId)
|
|
|
+ if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
|
|
+ throw new SubagentSessionOwnership(sessionId)
|
|
|
+ }
|
|
|
+ const publishedSession = ctx.sessions.get(sessionId)
|
|
|
+ const publishedAgent = ctx.agents.get(sessionId)
|
|
|
+ if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
|
|
|
+ throw new SubagentSessionOwnership(sessionId)
|
|
|
+ }
|
|
|
const handle = await ctx.agents.resume({
|
|
|
resumeSessionId: sessionId,
|
|
|
agentOptions,
|
|
|
@@ -1048,27 +1097,77 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
if (error instanceof SessionNotFound) {
|
|
|
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
|
|
}
|
|
|
+ if (error instanceof SubagentSessionOwnership) {
|
|
|
+ return { error: subagentOwnershipError(error.sessionId) }
|
|
|
+ }
|
|
|
// The internal details slot is contractually {}; the reason rides the message.
|
|
|
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ type SessionReadState = {
|
|
|
+ id: SessionId
|
|
|
+ header: SessionHeader
|
|
|
+ events: SessionEvent[]
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Read one stable session prefix without acquiring an Agent owner. */
|
|
|
+ async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
|
|
|
+ const attached = ctx.sessions.get(sessionId)
|
|
|
+ if (attached !== undefined) {
|
|
|
+ return {
|
|
|
+ id: attached.id,
|
|
|
+ header: attached.header,
|
|
|
+ events: [...attached.events],
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const inspected = await inspectServable(sessionId)
|
|
|
+ return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
|
|
|
+ async function historyStateFor(
|
|
|
+ sessionId: SessionId,
|
|
|
+ includeProjections: boolean,
|
|
|
+ ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
|
|
|
+ const attached = ctx.sessions.get(sessionId)
|
|
|
+ if (attached !== undefined) {
|
|
|
+ const events = [...attached.events]
|
|
|
+ const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
|
|
|
+ return { events, ...projections === undefined ? {} : { projections } }
|
|
|
+ }
|
|
|
+ const inspected = await inspectServable(sessionId)
|
|
|
+ const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
|
|
|
+ return {
|
|
|
+ events: inspected.events,
|
|
|
+ ...projections === undefined ? {} : { projections },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/** Resolve one requested identity to a live agent, creating or resuming it once. */
|
|
|
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
|
|
|
let creation = sessionCreations.get(sessionId)
|
|
|
if (creation === undefined) {
|
|
|
creation = (async () => {
|
|
|
+ const attached = ctx.sessions.get(sessionId)
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
+ if (attached !== undefined && hasSubagentOwner(attached, live)) {
|
|
|
+ throw new SubagentSessionOwnership(sessionId)
|
|
|
+ }
|
|
|
if (live !== undefined) return live
|
|
|
|
|
|
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
|
|
|
const stored = persistence === undefined
|
|
|
? undefined
|
|
|
: (await persistence.list()).find(header => header.id === sessionId)
|
|
|
- if (stored !== undefined) {
|
|
|
+ if (persistence !== undefined && stored !== undefined) {
|
|
|
if (stored.cwd !== cwd) {
|
|
|
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
|
|
|
}
|
|
|
+ const inspected = await persistence.inspect(sessionId)
|
|
|
+ if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
|
|
+ throw new SubagentSessionOwnership(sessionId)
|
|
|
+ }
|
|
|
return (await ctx.agents.resume({
|
|
|
resumeSessionId: sessionId,
|
|
|
agentOptions,
|
|
|
@@ -1091,7 +1190,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
// Another Host entry path may have published the same identity while
|
|
|
// this operation crossed an asynchronous persistence/filesystem step.
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
- if (live !== undefined) return live
|
|
|
+ if (live !== undefined) {
|
|
|
+ if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
|
|
|
+ return live
|
|
|
+ }
|
|
|
+ const attached = ctx.sessions.get(sessionId)
|
|
|
+ if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
|
|
|
+ throw new SubagentSessionOwnership(sessionId)
|
|
|
+ }
|
|
|
throw error
|
|
|
}).finally(() => {
|
|
|
sessionCreations.delete(sessionId)
|
|
|
@@ -1099,6 +1205,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
sessionCreations.set(sessionId, creation)
|
|
|
}
|
|
|
const agent = await creation
|
|
|
+ if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
|
|
|
if (agent.session.header.cwd !== cwd) {
|
|
|
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
|
|
}
|
|
|
@@ -1498,6 +1605,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
},
|
|
|
})
|
|
|
}
|
|
|
+ if (error instanceof SubagentSessionOwnership) {
|
|
|
+ return err(request, subagentOwnershipError(error.sessionId))
|
|
|
+ }
|
|
|
return err(request, {
|
|
|
code: 'internal',
|
|
|
message: `failed to create session "${sessionId}": ${String(error)}`,
|
|
|
@@ -1520,18 +1630,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
|
async history(request) {
|
|
|
const { sessionId, beforeSeq, maxMessages } = request.payload
|
|
|
- const found = await agentFor(sessionId)
|
|
|
- if ('error' in found) return err(request, found.error)
|
|
|
- // Everything below the resume above is synchronous: the page slice,
|
|
|
- // the seq read, and the projection walk see one un-torn session state.
|
|
|
- const page = historyPage(ctx, found.agent.session.events, beforeSeq, maxMessages)
|
|
|
- // Baseline rider: tail page only — loadOlder (beforeSeq present) is
|
|
|
- // the one path that never needs a fresh projection baseline.
|
|
|
- const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
|
|
|
+ let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
|
|
|
+ try {
|
|
|
+ state = await historyStateFor(sessionId, beforeSeq === undefined)
|
|
|
+ } catch (error: unknown) {
|
|
|
+ if (error instanceof SessionNotFound) {
|
|
|
+ return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
|
|
|
+ }
|
|
|
+ return err(request, {
|
|
|
+ code: 'internal',
|
|
|
+ message: `history unavailable for session "${sessionId}": ${String(error)}`,
|
|
|
+ details: {},
|
|
|
+ })
|
|
|
+ }
|
|
|
+ const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
|
|
|
return ok(request, {
|
|
|
events: page.events,
|
|
|
hasMore: page.hasMore,
|
|
|
- ...projections === undefined ? {} : { projections },
|
|
|
+ ...state.projections === undefined ? {} : { projections: state.projections },
|
|
|
})
|
|
|
},
|
|
|
|
|
|
@@ -1606,9 +1722,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
|
async fork(request) {
|
|
|
const { sessionId, atSeq } = request.payload
|
|
|
- const found = await agentFor(sessionId)
|
|
|
- if ('error' in found) return err(request, found.error)
|
|
|
- const source = found.agent.session
|
|
|
+ let source: SessionReadState
|
|
|
+ try {
|
|
|
+ source = await readSessionState(sessionId)
|
|
|
+ } catch (error: unknown) {
|
|
|
+ if (error instanceof SessionNotFound) {
|
|
|
+ return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
|
|
|
+ }
|
|
|
+ return err(request, {
|
|
|
+ code: 'internal',
|
|
|
+ message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
|
|
|
+ details: {},
|
|
|
+ })
|
|
|
+ }
|
|
|
const events = source.events
|
|
|
// An in-log anchor belongs to the turn containing it and must never
|
|
|
// clip backward to an earlier completed turn. Omitted and past-end
|
|
|
@@ -1694,6 +1820,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
updateQueue(request) {
|
|
|
const { sessionId, itemId, action } = request.payload
|
|
|
const agent = ctx.agents.get(sessionId)
|
|
|
+ if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
|
|
|
+ return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
|
|
|
+ }
|
|
|
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
|
|
|
return Promise.resolve(err(request, {
|
|
|
code: 'queue-item-not-found',
|
|
|
@@ -1714,6 +1843,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
details: { sessionId },
|
|
|
}))
|
|
|
}
|
|
|
+ if (hasSubagentOwner(agent.session, agent)) {
|
|
|
+ return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
|
|
|
+ }
|
|
|
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
|
|
return Promise.resolve(ok(request, { accepted: true as const }))
|
|
|
},
|