fix: isolate concurrent OpenCode chat runs

This commit is contained in:
2026-08-17 22:03:37 +08:00
parent 7e8d9e3811
commit 65040730ec
29 changed files with 5207 additions and 427 deletions

View File

@@ -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;