diff --git a/src/main/index.ts b/src/main/index.ts index a0f92d1cf..41653bcae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,6 +39,7 @@ import { HarnessShell } from './harness-shell' import { HARNESS_SHELL_URL, HARNESS_BOOT_CHANNEL, HARNESS_BOOT_FAILED_CHANNEL, isHarnessShellUrl, isHarnessBootPage } from '../shared/harness-boot' import { DEFAULT_HARNESS_PORT, + extractDuplicateAgentPresetId, extractFailureCause, extractPluginFailureReferences, HarnessRuntime, @@ -113,7 +114,7 @@ import { pruneUnresolvableProfileBundles } from './state/plugin-recovery' import { ensureSafeModeProfile, SAFE_MODE_PROFILE } from './state/safe-mode-profile' -import { migrateLegacyAgentPresets } from './state/legacy-preset-migration' +import { disableMigratedLegacyPreset, migrateLegacyAgentPresets } from './state/legacy-preset-migration' import { WindowStateManager } from './state/window-state' import { isProjectedGenerationPlugin, @@ -286,6 +287,8 @@ let webImportActionResolver: ((action: WebImportAction) => void) | undefined let mainWindowNavigationVersion = 0 let rendererPluginFailureLogs: string[] = [] let pluginRecoveryRemovedPlugins: string[] = [] +/** Migrated preset rows auto-disabled because a plugin registers the same id at runtime. */ +let autoDisabledPresetIds = new Set() /** Plugins a deferred migration has not moved yet; their legacy copies may be manifest-only. */ let migrationPendingPlugins = new Set() let pluginRecoveryResetTimer: ReturnType | undefined @@ -2292,6 +2295,19 @@ async function showPluginRecovery(options?: { timeoutMs: waitForRendererEvidence ? PLUGIN_RECOVERY_EVIDENCE_TIMEOUT_MS : 0 }) if (!developmentDemoUnknown) detection.plugins = evidence.targets(detection.plugins, removedPlugins) + + // A preset the user's plugin registers at runtime can also live as a row the + // legacy migration generated; the duplicate id aborts startup and the row + // regenerates nothing only if it stays present. Disable that one row and + // relaunch: the plugin's own registration keeps the preset alive (#814). + const duplicatePresetId = extractDuplicateAgentPresetId(detection.logs) + if (duplicatePresetId && !autoDisabledPresetIds.has(duplicatePresetId)) { + if (await disableMigratedLegacyPreset(dshHome, duplicatePresetId, (line) => runtime.note(line))) { + autoDisabledPresetIds.add(duplicatePresetId) + await launchWithFreshEvidence() + continue + } + } // A plugin the deferred migration left manifest-only is not broken; removing // it would delete a working plugin because of an install-time failure. A // pending plugin whose legacy copy is installed can still be the culprit. diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index d5b7ccc2d..ad2a261a5 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -1024,6 +1024,17 @@ export function extractOffendingPlugins(logLines: readonly string[]): string[] { return extractPluginReferences(logLines, isActionablePluginReference) } +export function extractDuplicateAgentPresetId( + logLines: readonly string[] +): string | undefined { + for (const line of latestHarnessAttemptLogs(logLines)) { + if (!line.startsWith('[stderr] ')) continue + const match = line.slice(8).match(/Duplicate agent preset:\s*([^\s"']+)/i) + if (match?.[1]) return match[1].trim() + } + return undefined +} + export function extractDuplicateLoaderEntryId( logLines: readonly string[] ): string | undefined { diff --git a/src/main/state/legacy-preset-migration.ts b/src/main/state/legacy-preset-migration.ts index 333437d35..87ca4512e 100644 --- a/src/main/state/legacy-preset-migration.ts +++ b/src/main/state/legacy-preset-migration.ts @@ -119,3 +119,54 @@ export async function migrateLegacyAgentPresets(dshHome: string, note: (line: st await writeTextAtomically(profilePatch, next) note(`[desktop] converted ${pending.length} legacy agent presets for the web Profile; originals and backup retained`) } + +/** + * Disable the migrated row of one preset whose id a plugin already registers at + * runtime. The registry rejects a duplicate id, so startup aborts while both the + * row and the registration exist (#814). The row is kept — `existingIds` then + * still sees it as migrated and never rewrites it — but a disabled row takes no + * part in mounting or the startup audit, so the plugin's own registration is the + * single live preset on the next launch. + */ + +export async function disableMigratedLegacyPreset( + dshHome: string, + presetId: string, + note: (line: string) => void +): Promise { + if (!PRESET_ID.test(presetId)) return false + const profilePatch = join(dshHome, 'profiles', 'web', 'cordis.patch.yml') + const previous = await readFile(profilePatch, 'utf8').catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '' + throw error + }) + const lines = previous.split('\n') + // Block boundaries by line, not index-of-string: the preset id can appear in + // rows outside the migrated block, and only the migrated rows are ours to edit. + let blockStart = -1 + let blockEnd = -1 + for (let index = 0; index < lines.length; index++) { + const line = lines[index] as string + if (line.includes(START)) blockStart = index + else if (line.includes(END)) blockEnd = index + } + if (blockStart === -1 || blockEnd === -1 || blockEnd < blockStart) return false + + const wanted = `preset-${presetId}` + for (let index = blockStart; index <= blockEnd; index++) { + const match = /^(\s*)- id: (\S+)\s*$/.exec(lines[index] as string) + if (!match || match[2] !== wanted) continue + const fieldIndent = `${match[1]} ` + // The entry's own fields run until the next row at the entry indent. + for (let scan = index + 1; scan <= blockEnd; scan++) { + const later = lines[scan] as string + if (later && !later.startsWith(fieldIndent) && later.trimStart().startsWith('- ')) break + if (later.startsWith(`${fieldIndent}disabled:`)) return false + } + lines.splice(index + 1, 0, `${fieldIndent}disabled: true`) + await writeTextAtomically(profilePatch, lines.join('\n')) + note(`[desktop] disabled the migrated legacy preset row for "${presetId}"; a plugin registers it at runtime (#814)`) + return true + } + return false +} diff --git a/test/legacy-preset-migration.test.ts b/test/legacy-preset-migration.test.ts index 11dcb6ea9..88638060c 100644 --- a/test/legacy-preset-migration.test.ts +++ b/test/legacy-preset-migration.test.ts @@ -2,10 +2,14 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { spawn } from 'node:child_process' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import jsYaml from 'js-yaml' import { applyEntryPatches, entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import { migrateLegacyAgentPresets } from '../src/main/state/legacy-preset-migration' +import { + disableMigratedLegacyPreset, + migrateLegacyAgentPresets +} from '../src/main/state/legacy-preset-migration' +import { extractDuplicateAgentPresetId } from '../src/main/runtime/harness-runtime' import { HarnessRuntime } from '../src/main/runtime/harness-runtime' import { resolveTestNodeExecutable } from './node-executable' @@ -148,3 +152,88 @@ it('publishes a migrated custom preset through the real Harness web registry', a await runtime.stop() } }, 45_000) + +describe('disableMigratedLegacyPreset', () => { + const homes: string[] = [] + afterEach(async () => { + await Promise.all(homes.map((home) => rm(home, { recursive: true, force: true }))) + homes.length = 0 + }) + + async function patchHome(patch: string): Promise { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-disable-')) + homes.push(home) + await mkdir(join(home, 'profiles', 'web'), { recursive: true }) + await writeFile(join(home, 'profiles', 'web', 'cordis.patch.yml'), patch) + return home + } + + it('disables only the named migrated preset row and stays idempotent', async () => { + const home = await patchHome(`# dsh-desktop legacy presets begin +- insert: + - id: preset-aitoearn + name: '@deepseek-ai/dsh-agent-preset' + config: + id: aitoearn + plugins: + - aitoearn-plugin +- insert: + - id: preset-other + name: '@deepseek-ai/dsh-agent-preset' + config: + id: other + plugins: + - other-plugin +# dsh-desktop legacy presets end +`) + const changedFirst = await disableMigratedLegacyPreset(home, 'aitoearn', () => {}) + expect(changedFirst).toBe(true) + const first = await readFile(join(home, 'profiles', 'web', 'cordis.patch.yml'), 'utf8') + expect(first).toContain(' - id: preset-aitoearn\n disabled: true') + expect(first).not.toContain('preset-other\n disabled') + + const changedSecond = await disableMigratedLegacyPreset(home, 'aitoearn', () => {}) + expect(changedSecond).toBe(false) + const second = await readFile(join(home, 'profiles', 'web', 'cordis.patch.yml'), 'utf8') + expect(second.match(/disabled: true/g)).toHaveLength(1) + }) + + it('disables a built-in-style preset row at its own field indent', async () => { + const home = await patchHome(`# dsh-desktop legacy presets begin +- id: preset-standard + config: + id: standard + plugins: + - standard-plugin +# dsh-desktop legacy presets end +`) + expect(await disableMigratedLegacyPreset(home, 'standard', () => {})).toBe(true) + const text = await readFile(join(home, 'profiles', 'web', 'cordis.patch.yml'), 'utf8') + expect(text).toContain('- id: preset-standard\n disabled: true') + }) + + it('leaves rows outside the migration block alone', async () => { + const home = await patchHome(`- id: preset-aitoearn + config: + id: aitoearn +# dsh-desktop legacy presets begin +- insert: + - id: preset-real + name: '@deepseek-ai/dsh-agent-preset' +# dsh-desktop legacy presets end +`) + expect(await disableMigratedLegacyPreset(home, 'aitoearn', () => {})).toBe(false) + }) +}) + +describe('extractDuplicateAgentPresetId', () => { + it('reads the duplicate preset id from the latest stderr attempt logs', () => { + const logs = [ + '[stderr] older attempt: noise', + '[2026-10-27T00:00:00.000Z] attempt marker', + '[stderr] error agent-preset: Error: Duplicate agent preset: aitoearn' + ] + expect(extractDuplicateAgentPresetId(logs)).toBe('aitoearn') + expect(extractDuplicateAgentPresetId(['[stderr] unrelated'])).toBeUndefined() + }) +})