fix: isolate concurrent OpenCode chat runs
This commit is contained in:
319
electron/opencode/project-agent-runtime.ts
Normal file
319
electron/opencode/project-agent-runtime.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { ProjectConfig } from '../../shared/project-config';
|
||||
import type { OpencodeAgentInfo } from './client';
|
||||
import {
|
||||
buildProjectAgentManifest,
|
||||
readProjectConfig,
|
||||
} from './project-config';
|
||||
|
||||
interface ProjectAgentRuntimeManager {
|
||||
getRuntimeGeneration?: () => number;
|
||||
getRuntimeGenerationProvenance?: () => 'unknown' | 'starting' | 'fresh' | 'attached';
|
||||
}
|
||||
|
||||
interface ProjectAgentRegistryClient {
|
||||
listAgents: (options?: { signal?: AbortSignal }) => Promise<OpencodeAgentInfo[]>;
|
||||
}
|
||||
|
||||
type RuntimeGenerationProvenance = 'unknown' | 'starting' | 'fresh' | 'attached';
|
||||
|
||||
interface ProjectAgentRuntimeState {
|
||||
runtimeGeneration: number;
|
||||
runtimeGenerationProvenance: RuntimeGenerationProvenance;
|
||||
desiredFingerprint: string;
|
||||
appliedFingerprint: string | null;
|
||||
bootstrapCandidateFingerprint: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectAgentRuntimeSnapshot extends ProjectAgentRuntimeState {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export interface ProjectAgentPreflightResult {
|
||||
ready: boolean;
|
||||
runtimeGeneration: number;
|
||||
}
|
||||
|
||||
export type ProjectAgentAcceptanceResult<T> =
|
||||
| { ready: false; runtimeGeneration: number }
|
||||
| { ready: true; runtimeGeneration: number; value: T };
|
||||
|
||||
export interface ProjectAgentRuntimeMutation<T> {
|
||||
previousConfig: ProjectConfig | null;
|
||||
config: ProjectConfig;
|
||||
value: T;
|
||||
}
|
||||
|
||||
const runtimeStates = new WeakMap<object, Map<string, ProjectAgentRuntimeState>>();
|
||||
const runtimeLocks = new WeakMap<object, Map<string, Promise<void>>>();
|
||||
|
||||
function canonicalProjectPath(projectPath: string): string {
|
||||
try {
|
||||
return realpathSync.native(projectPath);
|
||||
} catch {
|
||||
return path.resolve(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
function getManagerMap<T>(registry: WeakMap<object, Map<string, T>>, manager: object): Map<string, T> {
|
||||
let entries = registry.get(manager);
|
||||
if (!entries) {
|
||||
entries = new Map<string, T>();
|
||||
registry.set(manager, entries);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function withProjectRuntimeLock<T>(
|
||||
manager: object,
|
||||
projectPath: string,
|
||||
operation: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const locks = getManagerMap(runtimeLocks, manager);
|
||||
const previous = locks.get(projectPath) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const queued = previous.then(() => current);
|
||||
locks.set(projectPath, queued);
|
||||
try {
|
||||
await awaitAbortable(previous, signal);
|
||||
signal?.throwIfAborted();
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
void queued.then(() => {
|
||||
if (locks.get(projectPath) === queued) {
|
||||
locks.delete(projectPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadValidProjectConfig(projectPath: string): Promise<ProjectConfig> {
|
||||
const result = await readProjectConfig(projectPath);
|
||||
if (result.status !== 'valid') {
|
||||
throw new Error('Project configuration is missing or invalid');
|
||||
}
|
||||
return result.config;
|
||||
}
|
||||
|
||||
function observeState(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
canonicalPath: string,
|
||||
config: ProjectConfig,
|
||||
): ProjectAgentRuntimeState {
|
||||
const states = getManagerMap(runtimeStates, manager as object);
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
||||
const desiredFingerprint = buildProjectAgentManifest(config).fingerprint;
|
||||
const current = states.get(canonicalPath);
|
||||
if (!current || current.runtimeGeneration !== runtimeGeneration) {
|
||||
const next = {
|
||||
runtimeGeneration,
|
||||
runtimeGenerationProvenance: provenance,
|
||||
desiredFingerprint,
|
||||
appliedFingerprint: provenance === 'fresh' ? desiredFingerprint : null,
|
||||
bootstrapCandidateFingerprint: provenance === 'starting' ? desiredFingerprint : null,
|
||||
};
|
||||
states.set(canonicalPath, next);
|
||||
return next;
|
||||
}
|
||||
current.desiredFingerprint = desiredFingerprint;
|
||||
if (provenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
} else if (current.runtimeGenerationProvenance === 'starting') {
|
||||
current.appliedFingerprint = current.bootstrapCandidateFingerprint === desiredFingerprint
|
||||
? desiredFingerprint
|
||||
: null;
|
||||
} else if (current.runtimeGenerationProvenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
}
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
return current;
|
||||
}
|
||||
|
||||
export async function observeProjectAgentRuntime(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
projectPath: string,
|
||||
knownConfig?: ProjectConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProjectAgentRuntimeSnapshot> {
|
||||
const canonicalPath = canonicalProjectPath(projectPath);
|
||||
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
||||
signal?.throwIfAborted();
|
||||
const config = knownConfig ?? await loadValidProjectConfig(canonicalPath);
|
||||
signal?.throwIfAborted();
|
||||
const state = observeState(manager, canonicalPath, config);
|
||||
return { projectPath: canonicalPath, ...state };
|
||||
}, signal);
|
||||
}
|
||||
|
||||
export async function observeProjectAgentRuntimeGeneration(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
projectPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProjectAgentRuntimeSnapshot> {
|
||||
const canonicalPath = canonicalProjectPath(projectPath);
|
||||
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
||||
signal?.throwIfAborted();
|
||||
const states = getManagerMap(runtimeStates, manager as object);
|
||||
const current = states.get(canonicalPath);
|
||||
if (!current) {
|
||||
const config = await loadValidProjectConfig(canonicalPath);
|
||||
signal?.throwIfAborted();
|
||||
const state = observeState(manager, canonicalPath, config);
|
||||
return { projectPath: canonicalPath, ...state };
|
||||
}
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
||||
if (current.runtimeGeneration !== runtimeGeneration) {
|
||||
current.runtimeGeneration = runtimeGeneration;
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
current.appliedFingerprint = provenance === 'fresh' ? current.desiredFingerprint : null;
|
||||
current.bootstrapCandidateFingerprint = provenance === 'starting'
|
||||
? current.desiredFingerprint
|
||||
: null;
|
||||
} else if (provenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
} else if (current.runtimeGenerationProvenance === 'starting') {
|
||||
current.appliedFingerprint = current.bootstrapCandidateFingerprint === current.desiredFingerprint
|
||||
? current.desiredFingerprint
|
||||
: null;
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
} else if (current.runtimeGenerationProvenance !== 'fresh') {
|
||||
current.appliedFingerprint = null;
|
||||
current.runtimeGenerationProvenance = provenance;
|
||||
}
|
||||
return { projectPath: canonicalPath, ...current };
|
||||
}, signal);
|
||||
}
|
||||
|
||||
export async function markProjectAgentRuntimePending(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
projectPath: string,
|
||||
config: ProjectConfig,
|
||||
): Promise<ProjectAgentRuntimeSnapshot> {
|
||||
const canonicalPath = canonicalProjectPath(projectPath);
|
||||
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
||||
const state = {
|
||||
runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0,
|
||||
runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown',
|
||||
desiredFingerprint: buildProjectAgentManifest(config).fingerprint,
|
||||
appliedFingerprint: null,
|
||||
bootstrapCandidateFingerprint: null,
|
||||
};
|
||||
getManagerMap(runtimeStates, manager as object).set(canonicalPath, state);
|
||||
return { projectPath: canonicalPath, ...state };
|
||||
});
|
||||
}
|
||||
|
||||
export async function mutateProjectAgentRuntime<T>(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
projectPath: string,
|
||||
mutation: () => Promise<ProjectAgentRuntimeMutation<T>>,
|
||||
): Promise<T> {
|
||||
const canonicalPath = canonicalProjectPath(projectPath);
|
||||
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
||||
const result = await mutation();
|
||||
if (result.previousConfig) {
|
||||
observeState(manager, canonicalPath, result.previousConfig);
|
||||
observeState(manager, canonicalPath, result.config);
|
||||
} else {
|
||||
const state: ProjectAgentRuntimeState = {
|
||||
runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0,
|
||||
runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown',
|
||||
desiredFingerprint: buildProjectAgentManifest(result.config).fingerprint,
|
||||
appliedFingerprint: null,
|
||||
bootstrapCandidateFingerprint: null,
|
||||
};
|
||||
getManagerMap(runtimeStates, manager as object).set(canonicalPath, state);
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
}
|
||||
|
||||
function liveAgentIds(agents: OpencodeAgentInfo[]): Set<string> {
|
||||
return new Set(agents.flatMap((agent) => {
|
||||
const name = typeof agent.name === 'string' ? agent.name.trim() : '';
|
||||
const id = typeof agent.id === 'string' ? agent.id.trim() : '';
|
||||
return [name, id].filter(Boolean);
|
||||
}));
|
||||
}
|
||||
|
||||
async function awaitAbortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return await promise;
|
||||
signal.throwIfAborted();
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptProjectAgentRuntime<T>(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
projectPath: string,
|
||||
client: ProjectAgentRegistryClient,
|
||||
selectedAgentId: string,
|
||||
accept: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProjectAgentAcceptanceResult<T>> {
|
||||
const canonicalPath = canonicalProjectPath(projectPath);
|
||||
return await withProjectRuntimeLock(manager as object, canonicalPath, async () => {
|
||||
signal?.throwIfAborted();
|
||||
const config = await loadValidProjectConfig(canonicalPath);
|
||||
signal?.throwIfAborted();
|
||||
const state = observeState(manager, canonicalPath, config);
|
||||
const configuredAgentIds = config.agents.map((agent) => agent.id);
|
||||
if (
|
||||
!configuredAgentIds.includes(selectedAgentId)
|
||||
|| state.appliedFingerprint !== state.desiredFingerprint
|
||||
) {
|
||||
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const liveIds = liveAgentIds(await awaitAbortable(client.listAgents({ signal }), signal));
|
||||
if (configuredAgentIds.some((agentId) => !liveIds.has(agentId))) {
|
||||
state.appliedFingerprint = null;
|
||||
return { ready: false, runtimeGeneration: state.runtimeGeneration };
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const value = await awaitAbortable(accept(), signal);
|
||||
return {
|
||||
ready: true,
|
||||
runtimeGeneration: state.runtimeGeneration,
|
||||
value,
|
||||
};
|
||||
}, signal);
|
||||
}
|
||||
|
||||
export async function preflightProjectAgentRuntime(
|
||||
manager: ProjectAgentRuntimeManager,
|
||||
projectPath: string,
|
||||
client: ProjectAgentRegistryClient,
|
||||
selectedAgentId: string,
|
||||
): Promise<ProjectAgentPreflightResult> {
|
||||
const result = await acceptProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
client,
|
||||
selectedAgentId,
|
||||
async () => undefined,
|
||||
);
|
||||
return { ready: result.ready, runtimeGeneration: result.runtimeGeneration };
|
||||
}
|
||||
Reference in New Issue
Block a user