import { controllerResourceAttachmentLimit, controllerRuntimeIdKey, type TControllerResource, type TControllerResourceAttachmentTarget, type TControllerSessionId, } from '../ts_interfaces/index.js'; import type { SmartDataAuthStore } from './classes.authstore.js'; import type { IControllerResourceAttachmentEntryDocument, IControllerResourceDocument, IControllerResourceTerminalTargetDocument, } from './interfaces.projects.js'; import { applyResourceAttachmentIntent, cloneResourceAttachmentEntry, findSessionAttachment, hasExactSessionAttachment, hasExactTerminalAttachment, hasSessionAttachmentOfHarness, isResourceAttached, resourceAttachmentEntryKey, resourceSessionEntries, resourceTerminalEntries, } from './functions.resourceattachments.js'; import { type IFlexBrowserResourceDescriptor, } from './interfaces.flexipc.js'; export interface IControllerResourceRuntimeHost { startResource( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise; stopResource( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise; isRunning(resourceArg: IControllerResourceDocument): boolean; isAvailable(): boolean; renameResource?(resourceArg: IControllerResourceDocument): Promise; reconcileAttachment( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise; retireResource( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise; } export interface IControllerResourceCoordinatorOptions { store: Pick< SmartDataAuthStore, | 'listResources' | 'getResource' | 'listRecoverableResources' | 'createResource' | 'renameResource' | 'beginResourceAttachmentTransition' | 'commitResourceAttachmentTransition' | 'cancelResourceAttachmentTransition' | 'beginResourceRetirement' | 'completeResourceRetirement' >; terminalHost: IControllerResourceRuntimeHost; browserHost: IControllerResourceRuntimeHost; beforeResourceRetirement?( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise; isFlexHarnessAvailable(): boolean; resolveSessionAuthority( projectIdArg: string, sessionIdArg: TControllerSessionId, ): TControllerResourceSessionAuthoritySnapshot; resolveRetiredSessionIdentity( projectIdArg: string, sessionIdArg: TControllerSessionId, sessionIdentityIdArg: string, signalArg: AbortSignal, ): Promise; } /** What an MCP caller is allowed to act for, derived from its credential and never from input. */ export type TControllerResourceSubject = | { kind: 'session'; sessionId: TControllerSessionId; sessionIdentityId: string } /** `agentSessionId` is the conversation fence; absent for a plain shell terminal. */ | { kind: 'terminal'; resourceId: string; agentSessionId?: string } | { kind: 'runtime' }; export type TControllerResourceSessionAuthoritySnapshot = | Readonly<{ state: 'managed'; sessionIdentityId: string }> | Readonly<{ state: 'definitively_unmanaged' | 'uncertain'; sessionIdentityId?: never }>; export class ControllerResourceUnavailableError extends Error { public readonly code = 'resource_unavailable'; constructor(messageArg: string) { super(messageArg); this.name = 'ControllerResourceUnavailableError'; } } export class ControllerProjectHasResourcesError extends Error { public readonly code = 'project_has_resources'; constructor(public readonly resourceIds: string[]) { super('Retire every project resource before removing the project.'); this.name = 'ControllerProjectHasResourcesError'; } } interface IProjectMutationState { tail: Promise; admissionOpen: boolean; removed: boolean; } const sessionIdsEqual = ( leftArg: TControllerSessionId | null, rightArg: TControllerSessionId | null, ): boolean => leftArg === null ? rightArg === null : rightArg !== null && controllerRuntimeIdKey(leftArg) === controllerRuntimeIdKey(rightArg); export class ControllerResourceCoordinator { private readonly projectStates = new Map(); private readonly deletingSessions = new Set(); private readonly retiringResources = new Set(); constructor(private readonly options: IControllerResourceCoordinatorOptions) {} /** Admission closes before the first retirement write and stays closed on failure. */ public isResourceRetiring(projectIdArg: string, resourceIdArg: string): boolean { return this.retiringResources.has(JSON.stringify([projectIdArg, resourceIdArg])); } public async listResources( projectIdArg: string, signalArg: AbortSignal = AbortSignal.timeout(30_000), ): Promise { return this.runProjectMutation(projectIdArg, async () => { const resources = await this.reconcileResourceAuthorities( await this.options.store.listResources(projectIdArg, { signal: signalArg }), signalArg, ); return resources.map((resource) => this.toPublicResource(resource)); }); } public async reconcileProjectResources( projectIdArg: string, signalArg: AbortSignal, ): Promise { await this.runProjectMutation(projectIdArg, async () => { await this.reconcileResourceAuthorities( await this.options.store.listResources(projectIdArg, { signal: signalArg }), signalArg, ); }); } public async requireResourceKind( projectIdArg: string, resourceIdArg: string, kindArg: IControllerResourceDocument['kind'], ): Promise { return this.runProjectMutation(projectIdArg, async () => { const resource = (await this.options.store.listResources(projectIdArg)) .find((entry) => entry.id === resourceIdArg); if (!resource) return false; if (resource.kind !== kindArg) { throw new ControllerResourceUnavailableError(`The requested ${kindArg} resource is unavailable.`); } return true; }); } public async createResource( inputArg: Parameters[0], signalArg: AbortSignal, ): Promise { return this.runProjectMutation(inputArg.projectId, async () => { const resource = await this.options.store.createResource(inputArg); await this.hostFor(resource).startResource(resource, signalArg); const current = (await this.options.store.listResources(inputArg.projectId)) .find((entry) => entry.id === resource.id) ?? resource; return this.toPublicResource(current); }); } public async renameResource( projectIdArg: string, resourceIdArg: string, titleArg: string, ): Promise { return this.runProjectMutation(projectIdArg, async () => { const resource = await this.options.store.renameResource(projectIdArg, resourceIdArg, titleArg); await this.hostFor(resource).renameResource?.(resource); return this.toPublicResource(resource); }); } /** * Adds, removes or replaces one membership in the resource's attachment set. * * `add` is idempotent: attaching a subject that is already attached leaves the set as it is. * `replace` is what a Move uses, so the resource is never momentarily unattached. A remove with * no entry clears the whole set. */ public async changeAttachment(inputArg: { projectId: string; resourceId: string; op: 'add' | 'remove' | 'replace'; target: TControllerResourceAttachmentTarget | null; replaces?: TControllerResourceAttachmentTarget; expectedAttachmentRevision: number; signal: AbortSignal; }): Promise { return this.runProjectMutation(inputArg.projectId, async () => { const entry = inputArg.target === null ? undefined : await this.resolveAttachmentEntry( inputArg.projectId, inputArg.resourceId, inputArg.target, ); const replaces = inputArg.replaces === undefined ? undefined : await this.resolveRemovableEntry( inputArg.projectId, inputArg.resourceId, inputArg.replaces, ); if (entry !== undefined && inputArg.op !== 'remove') { await this.assertAttachmentSetFits( inputArg.projectId, inputArg.resourceId, entry, replaces, ); } const pending = await this.options.store.beginResourceAttachmentTransition( inputArg.projectId, inputArg.resourceId, { op: inputArg.op, ...(entry === undefined ? {} : { entry }), ...(replaces === undefined ? {} : { replaces }), }, inputArg.expectedAttachmentRevision, ); return this.toPublicResource(await this.reconcilePendingAttachment( pending, inputArg.signal, true, )); }); } /** * Refuses the attachment that would grow the set past the controller's own limit. The limit * sits below the browser runtime's capability cap, so a resource can never reach a size the * runtime rejects from inside a lifecycle path, where the refusal has nowhere to go. */ private async assertAttachmentSetFits( projectIdArg: string, resourceIdArg: string, entryArg: IControllerResourceAttachmentEntryDocument, replacesArg: IControllerResourceAttachmentEntryDocument | undefined, ): Promise { const resource = (await this.options.store.listResources(projectIdArg)) .find((candidateArg) => candidateArg.id === resourceIdArg); if (!resource) throw new ControllerResourceUnavailableError('The resource does not exist.'); const addedKey = resourceAttachmentEntryKey(entryArg); const supersededKey = replacesArg === undefined ? undefined : resourceAttachmentEntryKey(replacesArg); const retained = resource.attachments.filter((candidateArg) => { const key = resourceAttachmentEntryKey(candidateArg); return key !== addedKey && key !== supersededKey; }); if (retained.length + 1 > controllerResourceAttachmentLimit) { throw new ControllerResourceUnavailableError( `A resource can be attached to at most ${controllerResourceAttachmentLimit} subjects.`, ); } } /** Resolves an attach target into the exact entry that will be persisted. */ private async resolveAttachmentEntry( projectIdArg: string, resourceIdArg: string, targetArg: TControllerResourceAttachmentTarget, ): Promise { if (targetArg.kind === 'session') { if (this.deletingSessions.has(this.sessionKey(projectIdArg, targetArg.sessionId))) { throw new ControllerResourceUnavailableError('The target session is being deleted.'); } const authority = this.options.resolveSessionAuthority(projectIdArg, targetArg.sessionId); if (authority.state !== 'managed') { throw new ControllerResourceUnavailableError('The target session is not managed.'); } return { kind: 'session', id: { ...targetArg.sessionId }, projectId: projectIdArg, attachedAt: new Date(), sessionIdentityId: authority.sessionIdentityId, }; } const terminalTarget = await this.requireAttachableTerminalTarget( projectIdArg, resourceIdArg, targetArg.resourceId, ); return { kind: 'terminal', id: terminalTarget.resourceId, projectId: projectIdArg, attachedAt: new Date(), ...(terminalTarget.agentSessionId === undefined ? {} : { agentSessionId: terminalTarget.agentSessionId }), }; } /** * Resolves a target that is being removed. A removal must not re-prove the subject's authority: * the entry is being taken out precisely because the subject may no longer be usable. */ private async resolveRemovableEntry( projectIdArg: string, resourceIdArg: string, targetArg: TControllerResourceAttachmentTarget, ): Promise { const resource = (await this.options.store.listResources(projectIdArg)) .find((entry) => entry.id === resourceIdArg); if (!resource) throw new ControllerResourceUnavailableError('The resource does not exist.'); const existing = targetArg.kind === 'session' ? findSessionAttachment(resource, targetArg.sessionId) : resourceTerminalEntries(resource).find((entry) => entry.id === targetArg.resourceId); if (!existing) { throw new ControllerResourceUnavailableError('The resource is not attached to that subject.'); } return cloneResourceAttachmentEntry(existing); } public async retireResource( projectIdArg: string, resourceIdArg: string, signalArg: AbortSignal, ): Promise { await this.runProjectMutation(projectIdArg, async () => { signalArg.throwIfAborted(); const key = JSON.stringify([projectIdArg, resourceIdArg]); const resource = await this.options.store.getResource(projectIdArg, resourceIdArg, { includeRetired: true }); if (!resource) throw new ControllerResourceUnavailableError('The resource does not exist.'); if (resource.lifecycle === 'retired') { this.retiringResources.delete(key); return; } this.retiringResources.add(key); if (resource.kind === 'terminal') { await this.detachResourcesAttachedToTerminal(projectIdArg, resourceIdArg, signalArg); } await this.options.beforeResourceRetirement?.(resource, signalArg); signalArg.throwIfAborted(); const retiring = await this.options.store.beginResourceRetirement(projectIdArg, resourceIdArg); if (retiring.lifecycle !== 'retired') { await this.hostFor(retiring).retireResource(retiring, signalArg); await this.options.store.completeResourceRetirement(projectIdArg, resourceIdArg); } this.retiringResources.delete(key); }); } public async startResource( projectIdArg: string, resourceIdArg: string, signalArg: AbortSignal, ): Promise { return this.runProjectMutation(projectIdArg, async () => { const resource = (await this.options.store.listResources(projectIdArg)) .find((entry) => entry.id === resourceIdArg); if (!resource || resource.lifecycle !== 'active' || resource.pendingAttachment) { throw new ControllerResourceUnavailableError('The resource cannot be started now.'); } await this.hostFor(resource).startResource(resource, signalArg); const current = (await this.options.store.listResources(projectIdArg)) .find((entry) => entry.id === resourceIdArg) ?? resource; return this.toPublicResource(current); }); } public async stopResource( projectIdArg: string, resourceIdArg: string, signalArg: AbortSignal, ): Promise { return this.runProjectMutation(projectIdArg, async () => { const resource = (await this.options.store.listResources(projectIdArg)) .find((entry) => entry.id === resourceIdArg); if (!resource || resource.lifecycle !== 'active') { throw new ControllerResourceUnavailableError('The resource cannot be stopped now.'); } await this.hostFor(resource).stopResource(resource, signalArg); const current = (await this.options.store.listResources(projectIdArg)) .find((entry) => entry.id === resourceIdArg) ?? resource; return this.toPublicResource(current); }); } public async recover(signalArg: AbortSignal): Promise { for (const resource of await this.options.store.listRecoverableResources()) { signalArg.throwIfAborted(); await this.runProjectMutation(resource.projectId, async () => { if (resource.lifecycle === 'retiring') { const key = JSON.stringify([resource.projectId, resource.id]); this.retiringResources.add(key); await this.options.beforeResourceRetirement?.(resource, signalArg); await this.hostFor(resource).retireResource(resource, signalArg); await this.options.store.completeResourceRetirement(resource.projectId, resource.id); this.retiringResources.delete(key); return; } if (resource.pendingAttachment) { const reconciled = await this.reconcilePendingAttachment(resource, signalArg); await this.reconcileResourceAuthorities([reconciled], signalArg); return; } const [authorized] = await this.reconcileResourceAuthorities([resource], signalArg); await this.hostFor(authorized).reconcileAttachment(authorized, signalArg); }); } } public async withSessionDeletion( projectIdArg: string, sessionIdArg: TControllerSessionId, signalArg: AbortSignal, deleteArg: () => Promise, ): Promise { const sessionKey = this.sessionKey(projectIdArg, sessionIdArg); if (this.deletingSessions.has(sessionKey)) { throw new ControllerResourceUnavailableError('The session is already being deleted.'); } this.deletingSessions.add(sessionKey); try { await this.runProjectMutation(projectIdArg, async () => undefined); const result = await deleteArg(); await this.runProjectMutation( projectIdArg, () => this.reconcileSessionRetirementInProject(projectIdArg, sessionIdArg, signalArg), ); return result; } finally { this.deletingSessions.delete(sessionKey); } } public async reconcileSessionRetirement( projectIdArg: string, sessionIdArg: TControllerSessionId, signalArg: AbortSignal, ): Promise { await this.runProjectMutation( projectIdArg, () => this.reconcileSessionRetirementInProject(projectIdArg, sessionIdArg, signalArg), ); } public async withProjectRemovalGuard( projectIdArg: string, removeArg: () => Promise, ): Promise { const state = this.projectState(projectIdArg); if (!state.admissionOpen || state.removed) { throw new ControllerResourceUnavailableError('The project is not accepting resource operations.'); } state.admissionOpen = false; try { await state.tail; const resources = await this.options.store.listResources(projectIdArg); if (resources.length > 0) { throw new ControllerProjectHasResourcesError(resources.map((resource) => resource.id)); } const result = await removeArg(); state.removed = true; this.projectStates.delete(projectIdArg); return result; } catch (errorArg) { if (!state.removed) { state.admissionOpen = true; if (this.projectStates.get(projectIdArg) === state) { this.projectStates.delete(projectIdArg); } } throw errorArg; } } public async resolveFlexBrowserResources( projectIdArg: string, sessionIdArg: string, ): Promise { return this.runProjectMutation(projectIdArg, async () => { const resources = await this.options.store.listResources(projectIdArg); const authority = this.options.resolveSessionAuthority( projectIdArg, { harnessId: 'flex', nativeId: sessionIdArg }, ); if (authority.state !== 'managed') return []; return resources .filter((resource) => ( resource.kind === 'browser' && resource.lifecycle === 'active' && resource.pendingAttachment === undefined // The set contains an entry for this exact conversation. Other attached chats are // irrelevant here: each subject is gated on its own entry. && hasExactSessionAttachment( resource, { harnessId: 'flex', nativeId: sessionIdArg }, authority.sessionIdentityId, ) )) .sort((left, right) => left.id.localeCompare(right.id)) .map((resource) => ({ resourceId: resource.id, attachmentRevision: resource.attachmentRevision, })); }); } public async requireFlexBrowserResource(inputArg: { projectId: string; sessionId: string; resourceId: string; attachmentRevision: number; }): Promise { return this.runProjectMutation(inputArg.projectId, async () => { const resource = (await this.options.store.listResources(inputArg.projectId)) .find((entry) => entry.id === inputArg.resourceId); const authority = this.options.resolveSessionAuthority( inputArg.projectId, { harnessId: 'flex', nativeId: inputArg.sessionId }, ); if (authority.state !== 'managed') { throw new ControllerResourceUnavailableError('The browser session is not managed.'); } if ( !resource || resource.kind !== 'browser' || resource.lifecycle !== 'active' || resource.pendingAttachment !== undefined || resource.attachmentRevision !== inputArg.attachmentRevision || !hasExactSessionAttachment( resource, { harnessId: 'flex', nativeId: inputArg.sessionId }, authority.sessionIdentityId, ) ) throw new ControllerResourceUnavailableError('The browser attachment is no longer current.'); return resource; }); } /** * Only a browser may take a terminal subject, which makes an attachment cycle impossible by * construction instead of by cycle detection. */ private async requireAttachableTerminalTarget( projectIdArg: string, resourceIdArg: string, terminalResourceIdArg: string, ): Promise { if (terminalResourceIdArg === resourceIdArg) { throw new ControllerResourceUnavailableError('A resource cannot be attached to itself.'); } const resources = await this.options.store.listResources(projectIdArg); const attaching = resources.find((entry) => entry.id === resourceIdArg); if (!attaching || attaching.kind !== 'browser') { throw new ControllerResourceUnavailableError('Only a browser can be attached to a terminal.'); } const terminal = resources.find((entry) => entry.id === terminalResourceIdArg); if ( !terminal || terminal.kind !== 'terminal' || terminal.lifecycle !== 'active' || this.isResourceRetiring(projectIdArg, terminalResourceIdArg) ) { throw new ControllerResourceUnavailableError('The target terminal is unavailable.'); } return { resourceId: terminal.id, ...(terminal.terminal?.agent === undefined ? {} : { agentSessionId: terminal.terminal.agent.sessionId }), }; } /** * A terminal subject is proven from local state only, so it is never `uncertain`: the terminal * either exists, is active and still runs the recorded conversation, or the attachment is stale. */ private async resolveExactTerminalTargetAuthority( projectIdArg: string, terminalTargetArg: IControllerResourceTerminalTargetDocument, signalArg: AbortSignal, ): Promise<'exact' | 'invalid'> { const terminal = (await this.options.store.listResources(projectIdArg, { signal: signalArg })) .find((entry) => entry.id === terminalTargetArg.resourceId); if (!terminal || terminal.kind !== 'terminal' || terminal.lifecycle !== 'active') return 'invalid'; return terminal.terminal?.agent?.sessionId === terminalTargetArg.agentSessionId ? 'exact' : 'invalid'; } /** * The MCP browser gate. Identical in strength to {@link requireFlexBrowserResource}: exact * subject identity, exact attachment revision, and no attachment in flight. The subject comes * from the caller's credential, never from the request. */ public async requireSubjectBrowserResource(inputArg: { projectId: string; resourceId: string; attachmentRevision: number; subject: TControllerResourceSubject; }): Promise { return this.runProjectMutation(inputArg.projectId, async () => { const resource = (await this.options.store.listResources(inputArg.projectId)) .find((entry) => entry.id === inputArg.resourceId); if ( !resource || resource.kind !== 'browser' || resource.lifecycle !== 'active' || resource.pendingAttachment !== undefined || resource.attachmentRevision !== inputArg.attachmentRevision ) throw new ControllerResourceUnavailableError('The browser attachment is no longer current.'); if (inputArg.subject.kind === 'runtime') { // A shared OpenCode runtime may drive any browser attached to one of its own tasks in the // project it named. Coarse by construction, and recorded as such in the readme. if (!hasSessionAttachmentOfHarness(resource, 'opencode')) { throw new ControllerResourceUnavailableError( 'The browser is not attached to a task of this runtime.', ); } return resource; } if (inputArg.subject.kind === 'terminal') { // The conversation fence matters here, not only the terminal: a terminal restarted into a // different conversation is a different subject and inherits no attachment. if (!hasExactTerminalAttachment( resource, inputArg.subject.resourceId, inputArg.subject.agentSessionId, )) { throw new ControllerResourceUnavailableError( 'The browser is not attached to this terminal conversation.', ); } return resource; } // Another chat's entry grants this caller nothing: the set must contain this subject's own. if (!hasExactSessionAttachment( resource, inputArg.subject.sessionId, inputArg.subject.sessionIdentityId, )) { throw new ControllerResourceUnavailableError('The browser is not attached to this task.'); } return resource; }); } private async reconcilePendingAttachment( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, cancelledTargetIsErrorArg = false, ): Promise { const pending = resourceArg.pendingAttachment; if (!pending) return resourceArg; const prior: IControllerResourceDocument = { ...resourceArg }; delete prior.pendingAttachment; // Only an entry being installed needs proving; a removal is allowed precisely because the // subject may already be gone. const installed = pending.op === 'remove' ? undefined : pending.entry; const proveAuthority = async (): Promise<'exact' | 'invalid' | 'uncertain'> => { if (installed === undefined) return 'exact'; return installed.kind === 'terminal' ? this.resolveExactTerminalTargetAuthority( resourceArg.projectId, { resourceId: installed.id as string, ...(installed.agentSessionId === undefined ? {} : { agentSessionId: installed.agentSessionId }), }, signalArg, ) : this.resolveExactAttachmentAuthority( resourceArg.projectId, installed.id as TControllerSessionId, installed.sessionIdentityId, signalArg, ); }; const beforeAuthority = await proveAuthority(); if (beforeAuthority === 'uncertain') { throw new ControllerResourceUnavailableError( 'The pending resource attachment has uncertain session authority.', ); } if (beforeAuthority === 'invalid') { const cancelled = await this.restorePriorAndCancel(resourceArg, prior, signalArg); if (cancelledTargetIsErrorArg) { throw new ControllerResourceUnavailableError( 'The target session identity is no longer managed.', ); } return cancelled; } const target: IControllerResourceDocument = { ...resourceArg, attachmentRevision: pending.attachmentRevision, attachments: applyResourceAttachmentIntent(resourceArg.attachments, pending), }; delete target.pendingAttachment; await this.hostFor(resourceArg).reconcileAttachment(target, signalArg); const afterAuthority = await proveAuthority(); if (afterAuthority !== 'exact') { const cancelled = await this.restorePriorAndCancel(resourceArg, prior, signalArg); if (cancelledTargetIsErrorArg) { throw new ControllerResourceUnavailableError( 'The target session identity changed during attachment.', ); } return cancelled; } return this.options.store.commitResourceAttachmentTransition( resourceArg.projectId, resourceArg.id, pending.operationId, ); } /** * Drops every entry whose subject is no longer exactly authorized, one entry at a time. An * unauthorized entry never invalidates the rest of the set: the other attached subjects keep * their access. */ private async reconcileResourceAuthorities( resourcesArg: IControllerResourceDocument[], signalArg: AbortSignal, ): Promise { const resources: IControllerResourceDocument[] = []; for (let resource of resourcesArg) { signalArg.throwIfAborted(); if (resource.pendingAttachment) { resource = await this.reconcilePendingAttachment(resource, signalArg); } let settled = false; while (!settled) { signalArg.throwIfAborted(); settled = true; for (const entry of resource.attachments) { const authority = await this.resolveExactEntryAuthority(resource, entry, signalArg); if (authority === 'uncertain') { throw new ControllerResourceUnavailableError( 'A resource attachment has uncertain session authority.', ); } if (authority === 'exact') continue; const pending = await this.options.store.beginResourceAttachmentTransition( resource.projectId, resource.id, { op: 'remove', entry: cloneResourceAttachmentEntry(entry) }, resource.attachmentRevision, ); resource = await this.reconcilePendingAttachment(pending, signalArg); settled = false; break; } } resources.push(resource); } return resources; } private async resolveExactEntryAuthority( resourceArg: IControllerResourceDocument, entryArg: IControllerResourceAttachmentEntryDocument, signalArg: AbortSignal, ): Promise<'exact' | 'invalid' | 'uncertain'> { return entryArg.kind === 'terminal' ? this.resolveExactTerminalTargetAuthority( resourceArg.projectId, { resourceId: entryArg.id as string, ...(entryArg.agentSessionId === undefined ? {} : { agentSessionId: entryArg.agentSessionId }), }, signalArg, ) : this.resolveExactAttachmentAuthority( resourceArg.projectId, entryArg.id as TControllerSessionId, entryArg.sessionIdentityId, signalArg, ); } private async resolveExactAttachmentAuthority( projectIdArg: string, sessionIdArg: TControllerSessionId, sessionIdentityIdArg: string | undefined, signalArg: AbortSignal, ): Promise<'exact' | 'invalid' | 'uncertain'> { if (sessionIdentityIdArg === undefined) return 'invalid'; const authority = this.options.resolveSessionAuthority(projectIdArg, sessionIdArg); if (authority.state === 'uncertain') { signalArg.throwIfAborted(); return await this.options.resolveRetiredSessionIdentity( projectIdArg, sessionIdArg, sessionIdentityIdArg, signalArg, ) ? 'invalid' : 'uncertain'; } return authority.state === 'managed' && authority.sessionIdentityId === sessionIdentityIdArg ? 'exact' : 'invalid'; } /** * A deleted conversation loses its own membership only. Resources it shared with other chats * stay attached to those, which is the point of the set. */ private async reconcileSessionRetirementInProject( projectIdArg: string, sessionIdArg: TControllerSessionId, signalArg: AbortSignal, ): Promise { const resources = await this.options.store.listResources(projectIdArg, { signal: signalArg }); for (let resource of resources) { signalArg.throwIfAborted(); const pending = resource.pendingAttachment; const pendingEntry = pending?.entry; const pendingMatches = pendingEntry !== undefined && pendingEntry.kind === 'session' && sessionIdsEqual(pendingEntry.id as TControllerSessionId, sessionIdArg); const committed = findSessionAttachment(resource, sessionIdArg); if (!pendingMatches && committed === undefined) continue; if (pending) { if (pendingMatches && pending.op !== 'remove') { const authority = await this.resolveExactAttachmentAuthority( projectIdArg, pendingEntry!.id as TControllerSessionId, pendingEntry!.sessionIdentityId, signalArg, ); this.assertRetiredAttachmentAuthority(authority); const prior: IControllerResourceDocument = { ...resource }; delete prior.pendingAttachment; resource = await this.restorePriorAndCancel(resource, prior, signalArg); } else { resource = await this.reconcilePendingAttachment(resource, signalArg); } } const entry = findSessionAttachment(resource, sessionIdArg); if (entry === undefined) continue; const authority = await this.resolveExactAttachmentAuthority( projectIdArg, sessionIdArg, entry.sessionIdentityId, signalArg, ); this.assertRetiredAttachmentAuthority(authority); const removal = await this.options.store.beginResourceAttachmentTransition( projectIdArg, resource.id, { op: 'remove', entry: cloneResourceAttachmentEntry(entry) }, resource.attachmentRevision, ); await this.reconcilePendingAttachment(removal, signalArg); } } /** * A retiring terminal stops being an attachment subject before it stops existing, so its * dependents are detached first and never observe a dangling subject. */ private async detachResourcesAttachedToTerminal( projectIdArg: string, terminalResourceIdArg: string, signalArg: AbortSignal, ): Promise { const resources = await this.options.store.listResources(projectIdArg, { signal: signalArg }); for (let resource of resources) { signalArg.throwIfAborted(); const pendingEntry = resource.pendingAttachment?.entry; const pendingMatches = pendingEntry?.kind === 'terminal' && pendingEntry.id === terminalResourceIdArg; const committedMatches = resourceTerminalEntries(resource) .some((entry) => entry.id === terminalResourceIdArg); if (!pendingMatches && !committedMatches) continue; if (resource.pendingAttachment) { resource = await this.reconcilePendingAttachment(resource, signalArg); } // Only this terminal's own entries go; attachments to other subjects are untouched. for (const entry of resourceTerminalEntries(resource)) { if (entry.id !== terminalResourceIdArg) continue; const pending = await this.options.store.beginResourceAttachmentTransition( projectIdArg, resource.id, { op: 'remove', entry: cloneResourceAttachmentEntry(entry) }, resource.attachmentRevision, ); resource = await this.reconcilePendingAttachment(pending, signalArg); } } } private assertRetiredAttachmentAuthority( authorityArg: 'exact' | 'invalid' | 'uncertain', ): void { if (authorityArg === 'exact') { throw new ControllerResourceUnavailableError('Deleted session authority remained managed.'); } if (authorityArg === 'uncertain') { throw new ControllerResourceUnavailableError( 'Deleted session resource cleanup has uncertain session authority.', ); } } private async restorePriorAndCancel( resourceArg: IControllerResourceDocument, priorArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise { await this.hostFor(resourceArg).reconcileAttachment(priorArg, signalArg); return this.options.store.cancelResourceAttachmentTransition( resourceArg.projectId, resourceArg.id, resourceArg.pendingAttachment!.operationId, ); } private toPublicResource(resourceArg: IControllerResourceDocument): TControllerResource { const attachment = { authorityId: resourceArg.attachmentAuthorityId, revision: resourceArg.attachmentRevision, entries: resourceArg.attachments.map((entry) => ({ kind: entry.kind, id: entry.kind === 'session' ? { ...(entry.id as TControllerSessionId) } : entry.id, projectId: entry.projectId, attachedAt: entry.attachedAt.getTime(), ...(entry.sessionIdentityId === undefined ? {} : { sessionIdentityId: entry.sessionIdentityId }), ...(entry.agentSessionId === undefined ? {} : { agentSessionId: entry.agentSessionId }), })), }; // Availability is the best any attached subject can do: one unavailable dependency must not // hide a resource another attached conversation can drive. const agentAvailability = resourceArg.pendingAttachment ? 'transitioning' as const : !isResourceAttached(resourceArg) ? 'detached' as const // A terminal-attached browser is driven over MCP by the terminal's own agent, so it // depends on the browser runtime only, never on the Flex harness. : resourceTerminalEntries(resourceArg).length > 0 ? this.options.browserHost.isAvailable() ? 'available' as const : 'dependencyUnavailable' as const : resourceArg.kind === 'browser' ? hasSessionAttachmentOfHarness(resourceArg, 'flex') && this.options.browserHost.isAvailable() && this.options.isFlexHarnessAvailable() ? 'available' as const : 'dependencyUnavailable' as const : 'available' as const; const base = { id: resourceArg.id, projectId: resourceArg.projectId, kind: resourceArg.kind, title: resourceArg.title, lifecycle: resourceArg.lifecycle, attachment, agentAvailability, createdAt: resourceArg.createdAt.getTime(), updatedAt: resourceArg.updatedAt.getTime(), }; if (resourceArg.kind === 'browser') { return { ...base, kind: 'browser', browserRuntimeState: this.options.browserHost.isAvailable() ? 'available' : 'unavailable', }; } const terminal = resourceArg.terminal!; return { ...base, kind: 'terminal', command: terminal.command, cwd: terminal.cwd, processState: this.options.terminalHost.isRunning(resourceArg) ? 'running' : 'stopped', ...(terminal.stoppedAt === undefined ? {} : { stoppedAt: terminal.stoppedAt.getTime() }), ...(terminal.lastExitCode === undefined ? {} : { lastExitCode: terminal.lastExitCode }), ...(terminal.agent === undefined ? {} : { agent: { kind: terminal.agent.kind, sessionId: terminal.agent.sessionId, desiredState: terminal.agent.desiredState, ...(terminal.agent.launchMode === undefined ? {} : { launchMode: terminal.agent.launchMode }), ...(terminal.agent.lastFailure === undefined ? {} : { lastFailure: terminal.agent.lastFailure }), ...(terminal.agent.lastFailureMessage === undefined ? {} : { lastFailureMessage: terminal.agent.lastFailureMessage }), }, }), }; } /** * Brings agent-backed terminals back after a controller restart or upgrade. Deliberately not * part of recover(): reconcileAttachment also runs during every attachment transition, and a * respawn must happen exactly once, at startup. * * Failures are collected rather than thrown — one unresolvable agent binary must not prevent the * controller from starting — and are bounded by the persisted failure counter, so a permanently * broken chat degrades once instead of retrying on every boot forever. */ public async restartAgentTerminals( signalArg: AbortSignal, ): Promise> { const failures: Array<{ projectId: string; resourceId: string; error: unknown }> = []; for (const resource of await this.options.store.listRecoverableResources()) { signalArg.throwIfAborted(); if ( resource.kind !== 'terminal' || resource.lifecycle !== 'active' || resource.pendingAttachment || resource.terminal?.agent?.desiredState !== 'running' ) continue; if (this.options.terminalHost.isRunning(resource)) continue; try { await this.runProjectMutation(resource.projectId, async () => { await this.options.terminalHost.startResource(resource, signalArg); }); } catch (errorArg) { if (signalArg.aborted) throw errorArg; failures.push({ projectId: resource.projectId, resourceId: resource.id, error: errorArg }); } } return failures; } private hostFor(resourceArg: IControllerResourceDocument): IControllerResourceRuntimeHost { return resourceArg.kind === 'terminal' ? this.options.terminalHost : this.options.browserHost; } private projectState(projectIdArg: string): IProjectMutationState { let state = this.projectStates.get(projectIdArg); if (!state) { state = { tail: Promise.resolve(), admissionOpen: true, removed: false }; this.projectStates.set(projectIdArg, state); } return state; } private runProjectMutation(projectIdArg: string, operationArg: () => Promise): Promise { const state = this.projectState(projectIdArg); if (!state.admissionOpen || state.removed) { return Promise.reject(new ControllerResourceUnavailableError( 'The project is not accepting resource operations.', )); } const operation = state.tail.then(async () => { if (!state.admissionOpen || state.removed) { throw new ControllerResourceUnavailableError('The project stopped accepting resource operations.'); } return operationArg(); }); const tail = operation.then(() => undefined, () => undefined); state.tail = tail; void tail.finally(() => { if ( state.admissionOpen && !state.removed && state.tail === tail ) this.projectStates.delete(projectIdArg); }); return operation; } private sessionKey(projectIdArg: string, sessionIdArg: TControllerSessionId): string { return `${projectIdArg}:${controllerRuntimeIdKey(sessionIdArg)}`; } }