fix: isolate concurrent OpenCode chat runs
This commit is contained in:
@@ -81,6 +81,10 @@ export interface FindOpencodeFilesOptions {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface OpencodeRuntimeRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface UpdateOpencodeSessionInput {
|
||||
title?: string;
|
||||
}
|
||||
@@ -93,6 +97,12 @@ export interface OpencodeSkillInfo {
|
||||
entries?: OpencodeSkillEntry[];
|
||||
}
|
||||
|
||||
export interface OpencodeAgentInfo {
|
||||
name?: string;
|
||||
id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpencodeSkillEntry {
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
@@ -193,6 +203,7 @@ async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
export function createOpencodeClient(options: OpencodeClientOptions) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
init?.signal?.throwIfAborted();
|
||||
const decorated = decorateOpencodeRequest({
|
||||
baseUrl: options.baseUrl,
|
||||
path,
|
||||
@@ -253,23 +264,30 @@ export function createOpencodeClient(options: OpencodeClientOptions) {
|
||||
summarizeSession: (
|
||||
sessionID: string,
|
||||
payload: SummarizeOpencodeSessionInput,
|
||||
options?: OpencodeRuntimeRequestOptions,
|
||||
): Promise<boolean> =>
|
||||
request<boolean>(`/session/${encodeURIComponent(sessionID)}/summarize`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
}),
|
||||
executeSessionCommand: (
|
||||
sessionID: string,
|
||||
payload: ExecuteOpencodeSessionCommandInput,
|
||||
options?: OpencodeRuntimeRequestOptions,
|
||||
): Promise<unknown> =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/command`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
...opencodeCommandMethods,
|
||||
listAgents: (options?: OpencodeRuntimeRequestOptions) => request<OpencodeAgentInfo[]>('/agent', {
|
||||
signal: options?.signal,
|
||||
}),
|
||||
listSkills: () => request<OpencodeSkillInfo[]>('/skill'),
|
||||
listSessions: () => request<unknown[]>('/session'),
|
||||
createSession: (payload: Record<string, unknown>) => request<unknown>('/session', {
|
||||
@@ -313,7 +331,9 @@ export function createOpencodeClient(options: OpencodeClientOptions) {
|
||||
request<void>(`/question/${encodeURIComponent(requestID)}/reject`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
getConfig: () => request<Record<string, unknown>>('/config'),
|
||||
getConfig: (options?: OpencodeRuntimeRequestOptions) => request<Record<string, unknown>>('/config', {
|
||||
signal: options?.signal,
|
||||
}),
|
||||
listPermissions: () => request<unknown[]>('/permission'),
|
||||
replyPermission: (requestID: string, reply: OpencodePermissionReply, message?: string) =>
|
||||
request<void>(`/permission/${encodeURIComponent(requestID)}/reply`, {
|
||||
@@ -330,12 +350,17 @@ export function createOpencodeClient(options: OpencodeClientOptions) {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildTextPartsPayload(payload)),
|
||||
}),
|
||||
promptSessionAsync: (sessionID: string, payload: SendOpencodeSessionMessageInput) => {
|
||||
promptSessionAsync: (
|
||||
sessionID: string,
|
||||
payload: SendOpencodeSessionMessageInput,
|
||||
options?: OpencodeRuntimeRequestOptions,
|
||||
) => {
|
||||
const requestPayload = buildTextPartsPayload(payload);
|
||||
logger.info('[opencode-client] Sending prompt_async payload', summarizePromptPayload(sessionID, requestPayload));
|
||||
return request<void>(`/session/${encodeURIComponent(sessionID)}/prompt_async`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
},
|
||||
getFileStatuses: () => request<unknown[]>('/file/status'),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
export type OpencodeLifecycleState = 'stopped' | 'starting' | 'running' | 'error';
|
||||
export type OpencodeRuntimeGenerationProvenance = 'unknown' | 'starting' | 'fresh' | 'attached';
|
||||
|
||||
export interface OpencodeStatus {
|
||||
state: OpencodeLifecycleState;
|
||||
@@ -400,6 +401,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
private cancelStartsBeforeSequence = 0;
|
||||
private runtimeGeneration = 0;
|
||||
private activeRuntimeGeneration: number | null = null;
|
||||
private activeRuntimeGenerationProvenance: OpencodeRuntimeGenerationProvenance = 'unknown';
|
||||
private activeStartAttempt: RuntimeStartAttempt | null = null;
|
||||
private readonly trackedUnreleasedPorts = new Set<number>();
|
||||
private lifecycleBusy = false;
|
||||
@@ -423,6 +425,18 @@ export class OpencodeManager extends EventEmitter {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
getRuntimeGeneration(): number {
|
||||
return this.status.state === 'starting' || this.status.state === 'running'
|
||||
? this.activeRuntimeGeneration ?? 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
getRuntimeGenerationProvenance(): OpencodeRuntimeGenerationProvenance {
|
||||
return this.status.state === 'starting' || this.status.state === 'running'
|
||||
? this.activeRuntimeGenerationProvenance
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
getManagedConfigDir(): string | null {
|
||||
const userDataDir = this.options.userDataDir?.trim();
|
||||
return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null;
|
||||
@@ -518,6 +532,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
}
|
||||
if (this.activeRuntimeGeneration === generation) {
|
||||
this.activeRuntimeGeneration = null;
|
||||
this.activeRuntimeGenerationProvenance = 'unknown';
|
||||
}
|
||||
this.setStatus({ state: 'stopped', port: this.options.port });
|
||||
} catch (error) {
|
||||
@@ -685,6 +700,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
if (!listeningPort) return;
|
||||
|
||||
finish(() => {
|
||||
this.activeRuntimeGenerationProvenance = 'fresh';
|
||||
this.setStatus({
|
||||
state: 'running',
|
||||
port: listeningPort,
|
||||
@@ -738,6 +754,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
url: existingServer.url,
|
||||
},
|
||||
);
|
||||
this.activeRuntimeGenerationProvenance = 'attached';
|
||||
this.setStatus(existingServer);
|
||||
resolve(this.getStatus());
|
||||
});
|
||||
@@ -805,6 +822,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
controller: new AbortController(),
|
||||
};
|
||||
this.activeRuntimeGeneration = attempt.generation;
|
||||
this.activeRuntimeGenerationProvenance = 'starting';
|
||||
this.activeStartAttempt = attempt;
|
||||
return attempt;
|
||||
}
|
||||
@@ -997,6 +1015,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
this.recordPortReleaseOutcome(port, true, released);
|
||||
if (released) {
|
||||
this.activeRuntimeGeneration = null;
|
||||
this.activeRuntimeGenerationProvenance = 'unknown';
|
||||
this.setStatus({
|
||||
state: 'stopped',
|
||||
port: this.options.port,
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createProjectConfig,
|
||||
isProjectAgentAvatarDataUrl,
|
||||
@@ -164,6 +165,16 @@ export async function readProjectConfig(projectPath: string): Promise<ProjectCon
|
||||
}
|
||||
}
|
||||
|
||||
export async function readProjectConfigSnapshot(projectPath: string): Promise<ProjectConfigReadResult> {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
|
||||
return { status: 'valid', config: normalizeProjectConfig(raw) };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function yamlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -231,6 +242,40 @@ ${skills}
|
||||
${prompt}`;
|
||||
}
|
||||
|
||||
export interface ProjectAgentManifestEntry {
|
||||
relativePath: string;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
export interface ProjectAgentManifest {
|
||||
entries: ProjectAgentManifestEntry[];
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
export function buildProjectAgentManifest(config: ProjectConfig): ProjectAgentManifest {
|
||||
const entries = config.agents
|
||||
.map((agent) => {
|
||||
const content = buildAgentMarkdown(config, agent);
|
||||
return {
|
||||
relativePath: path.posix.join('agent', `${agent.id}.md`),
|
||||
content,
|
||||
contentHash: sha256(content),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => (
|
||||
left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0
|
||||
));
|
||||
const fingerprint = sha256(entries
|
||||
.map((entry) => `${entry.relativePath}\0${entry.contentHash}\n`)
|
||||
.join(''));
|
||||
return { entries, fingerprint };
|
||||
}
|
||||
|
||||
function buildSelectedSkillGuidance(skillIds: string[]): string {
|
||||
const guidance: string[] = [];
|
||||
if (skillIds.includes('frontend-slides')) {
|
||||
@@ -257,10 +302,11 @@ function buildSelectedSkillGuidance(skillIds: string[]): string {
|
||||
async function areMaterializedAgentsCurrent(projectPath: string, config: ProjectConfig): Promise<boolean> {
|
||||
if (!config.initialized || config.agents.length === 0) return true;
|
||||
|
||||
const results = await Promise.all(config.agents.map(async (agent) => {
|
||||
const manifest = buildProjectAgentManifest(config);
|
||||
const results = await Promise.all(manifest.entries.map(async (entry) => {
|
||||
try {
|
||||
const existing = await readFile(path.join(projectPath, '.opencode', 'agent', `${agent.id}.md`), 'utf8');
|
||||
return existing === buildAgentMarkdown(config, agent);
|
||||
const existing = await readFile(path.join(projectPath, '.opencode', entry.relativePath), 'utf8');
|
||||
return sha256(existing) === entry.contentHash;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
@@ -269,11 +315,41 @@ async function areMaterializedAgentsCurrent(projectPath: string, config: Project
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
async function materializeAgents(projectPath: string, config: ProjectConfig): Promise<void> {
|
||||
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
|
||||
await mkdir(agentDirectory, { recursive: true });
|
||||
await Promise.all(config.agents.map(async (agent) => {
|
||||
await writeFile(path.join(agentDirectory, `${agent.id}.md`), buildAgentMarkdown(config, agent), 'utf8');
|
||||
async function removeRetiredGeneratedAgents(
|
||||
projectPath: string,
|
||||
previousConfig: ProjectConfig,
|
||||
desiredManifest: ProjectAgentManifest,
|
||||
): Promise<void> {
|
||||
if (!previousConfig.initialized) return;
|
||||
const desiredPaths = new Set(desiredManifest.entries.map((entry) => entry.relativePath));
|
||||
const retiredEntries = buildProjectAgentManifest(previousConfig).entries
|
||||
.filter((entry) => !desiredPaths.has(entry.relativePath));
|
||||
await Promise.all(retiredEntries.map(async (entry) => {
|
||||
const filePath = path.join(projectPath, '.opencode', entry.relativePath);
|
||||
try {
|
||||
const existing = await readFile(filePath, 'utf8');
|
||||
if (sha256(existing) === entry.contentHash) {
|
||||
await unlink(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function materializeAgents(
|
||||
projectPath: string,
|
||||
config: ProjectConfig,
|
||||
previousConfig?: ProjectConfig,
|
||||
): Promise<void> {
|
||||
const manifest = buildProjectAgentManifest(config);
|
||||
if (previousConfig) {
|
||||
await removeRetiredGeneratedAgents(projectPath, previousConfig, manifest);
|
||||
}
|
||||
if (manifest.entries.length === 0) return;
|
||||
await mkdir(path.join(projectPath, '.opencode', 'agent'), { recursive: true });
|
||||
await Promise.all(manifest.entries.map(async (entry) => {
|
||||
await writeFile(path.join(projectPath, '.opencode', entry.relativePath), entry.content, 'utf8');
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -349,7 +425,7 @@ export async function createInitialProjectConfig(
|
||||
}
|
||||
|
||||
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
|
||||
const previous = await readProjectConfig(projectPath);
|
||||
const previous = await readProjectConfigSnapshot(projectPath);
|
||||
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
|
||||
const requestedProjectType = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as { projectType?: unknown }).projectType
|
||||
@@ -369,7 +445,7 @@ export async function writeProjectConfig(projectPath: string, value: unknown): P
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(`Invalid project contact configuration: ${validationErrors.join(', ')}`);
|
||||
}
|
||||
await materializeAgents(projectPath, config);
|
||||
await materializeAgents(projectPath, config, previous.config);
|
||||
}
|
||||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||
return config;
|
||||
|
||||
107
electron/opencode/runtime-config-readiness.ts
Normal file
107
electron/opencode/runtime-config-readiness.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export interface RuntimeConfigGenerationManager {
|
||||
getRuntimeGeneration?: () => number;
|
||||
getRuntimeGenerationProvenance?: () => 'unknown' | 'starting' | 'fresh' | 'attached';
|
||||
}
|
||||
|
||||
interface RuntimeConfigPendingLatch {
|
||||
runtimeGeneration: number;
|
||||
clearOnFreshGeneration: boolean;
|
||||
}
|
||||
|
||||
const pendingLatches = new WeakMap<object, RuntimeConfigPendingLatch>();
|
||||
const coordinatorTails = new WeakMap<object, Promise<void>>();
|
||||
|
||||
export interface RuntimeConfigCoordinatorLease {
|
||||
isActive: () => boolean;
|
||||
isRefreshPending: () => boolean;
|
||||
markRefreshPending: () => number;
|
||||
retainRefreshPending: () => number;
|
||||
}
|
||||
|
||||
function pendingForCurrentGeneration(manager: RuntimeConfigGenerationManager): boolean {
|
||||
const latch = pendingLatches.get(manager as object);
|
||||
if (!latch) return false;
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
const provenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown';
|
||||
if (
|
||||
latch.clearOnFreshGeneration
|
||||
&& runtimeGeneration !== latch.runtimeGeneration
|
||||
&& provenance === 'fresh'
|
||||
) {
|
||||
pendingLatches.delete(manager as object);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function withRuntimeConfigCoordinator<T>(
|
||||
manager: RuntimeConfigGenerationManager,
|
||||
operation: (lease: RuntimeConfigCoordinatorLease) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = manager as object;
|
||||
const previous = coordinatorTails.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const queued = previous.then(() => current);
|
||||
coordinatorTails.set(key, queued);
|
||||
await previous;
|
||||
let active = true;
|
||||
try {
|
||||
return await operation({
|
||||
isActive: () => active,
|
||||
isRefreshPending: () => active ? pendingForCurrentGeneration(manager) : true,
|
||||
markRefreshPending: () => {
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
if (active) pendingLatches.set(key, { runtimeGeneration, clearOnFreshGeneration: true });
|
||||
return runtimeGeneration;
|
||||
},
|
||||
retainRefreshPending: () => {
|
||||
const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0;
|
||||
if (active) pendingLatches.set(key, { runtimeGeneration, clearOnFreshGeneration: false });
|
||||
return runtimeGeneration;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
active = false;
|
||||
release();
|
||||
if (coordinatorTails.get(key) === queued) coordinatorTails.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markRuntimeConfigRefreshPending(
|
||||
manager: RuntimeConfigGenerationManager,
|
||||
): Promise<number> {
|
||||
return await withRuntimeConfigCoordinator(manager, async (lease) => lease.markRefreshPending());
|
||||
}
|
||||
|
||||
export async function isRuntimeConfigRefreshPending(
|
||||
manager: RuntimeConfigGenerationManager,
|
||||
): Promise<boolean> {
|
||||
return await withRuntimeConfigCoordinator(manager, async (lease) => lease.isRefreshPending());
|
||||
}
|
||||
|
||||
export async function withRuntimeAcceptanceTimeout<T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timeout!: ReturnType<typeof setTimeout>;
|
||||
let timeoutFallback: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutError = new Error('OpenCode runtime acceptance timed out');
|
||||
const timedOut = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
timeoutFallback = setTimeout(() => reject(timeoutError), 0);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
controller.signal.throwIfAborted();
|
||||
const accepted = operation(controller.signal);
|
||||
return await Promise.race([accepted, timedOut]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (timeoutFallback) clearTimeout(timeoutFallback);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user