fix: isolate concurrent OpenCode chat runs
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user