Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>()
/** Plugins a deferred migration has not moved yet; their legacy copies may be manifest-only. */
let migrationPendingPlugins = new Set<string>()
let pluginRecoveryResetTimer: ReturnType<typeof setTimeout> | undefined
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions src/main/runtime/harness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions src/main/state/legacy-preset-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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
}
93 changes: 91 additions & 2 deletions test/legacy-preset-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string> {
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()
})
})