From d2aa72853a277a3962dd84064067d159c85d4d86 Mon Sep 17 00:00:00 2001 From: "yu.xia" Date: Thu, 29 Oct 2026 12:43:15 +0800 Subject: [PATCH] fix(startup): auto-disable a migrated preset row a plugin registers again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an installed plugin registers a preset at runtime and the same id also exists as a legacy directory, the migration row and the registration collide in the registry and abort every startup, and the row regenerates nothing only while it exists, so relaunching never clears it. Extract 'Duplicate agent preset: ' from the harness attempt logs, disable that one migrated row in place (a disabled row neither mounts nor joins the startup audit, and existingIds still sees it as migrated), then relaunch — the plugin's own registration remains the single live preset. Fixes #814 --- src/main/index.ts | 18 ++++- src/main/runtime/harness-runtime.ts | 11 +++ src/main/state/legacy-preset-migration.ts | 51 +++++++++++++ test/legacy-preset-migration.test.ts | 93 ++++++++++++++++++++++- 4 files changed, 170 insertions(+), 3 deletions(-) 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() + }) +})