Fix "Service is already registered" in DeepSeek Harness

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginService is already registeredcordis presetprocess singleton
A second cordis-preset session fails to mount with "Service is already registered". Failed resumes and flickering sessions trace to a process-global registry.

If a second cordis-based preset session in the same host process fails with Host Cordis inspect provider "Service" is already registered, your session is not broken — a process-global singleton registry was registered twice. CordisInspectRegistryService keeps a process-wide provider Map that throws on any duplicate id, while @deepseek-ai/dsh-tool-cordis unconditionally registers the four first-party providers (Service, Event, Builtin, Tool) on every preset mount. Combined with standing mounts that are never unmounted, the first mount permanently owns all four ids, and every other cordis-based preset's resume and session creation fails from then on until the process restarts.

DSH plugin: two trigger shapes — failed resume and a flickering session creation

One registry, two very different user-visible shapes — one fails loudly, the other says nothing at all in the UI. Taking them in turn:

  1. Shape one: resume fails outright (the original report): resuming a session whose agent preset includes the Cordis toolset reports:
resume failed for session "session-…": Error: agent-presets: preset "cordis" failed to mount:
failed to apply loader entry tool-cordis (@deepseek-ai/dsh-tool-cordis):
Host Cordis inspect provider "Service" is already registered

Once this happens the conversation can no longer be reopened at all — every prompt attempt fails the same way. The session's stored data is intact (a mount-time failure only, not data corruption). Reported on DSH plugin 0.1.0-rc.6, web profile, Windows, reproduced while running two sessions in one host process: one on the shipped cordis preset and one on a locally authored preset that also mounts a tool-cordis row (#1415). 2. Two concrete forms of shape one: (a) a session on the shipped cordis preset was torn down abnormally (a command error interrupted its last turn) and left its four provider ids behind in the process-global registry, so every subsequent resume of that same session collided with its own leftover registration; (b) after a host restart, resuming the same session while a locally authored preset (a patched copy of dsh-tool-cordis) was already live failed identically, because the vanilla package threw on Service (#1415). 3. Shape two: creating a session after editing a preset fails: on a second Windows machine running the shipped cordis-zcode preset, after editing the preset composition and trying to create a new session in the same running dsh web process, creation failed at preset mount with the exact same error. The evidence chain matters: sessions.create() (bare, no preset) succeeded and the session log row was created; what actually threw was the mount step (standingKeyFor → compose → apply tool-cordis) on the second registration of Service. And the "stale session" was a 319-byte empty log created before any plugin work that evening — proving the collision existed as soon as the first cordis-based session had mounted in that process, making "I edited the preset" a mere coincidence (#1415). 4. Shape three: hot-reload the same preset and the UI flicks back: edit ~/.dsh/profiles/web/cordis.patch.yml (or simply edit the agent preset file), let HMR apply, then click "New Session" → choose a workspace → the UI flicks back to 'no workspace selected' and the session cannot be created, with nothing shown in the interface. Same registry: on a preset file stamp change, ensureStanding() deletes only the standing map pointer without disposing the old generation, then recursively mounts the new one, so the old generation's four Host inspect providers never unwind. The backend produced the verbatim already registered error — and the Web frontend dropped the complete agent-preset-invalid payload (preset name, loader entry, package, conflicting object, file path). The reporter had to reverse-engineer it to find the cause: the same failure takes ten minutes with the error and a whole day without it (#902). 5. Blast radius and the operational rule: once a process hosts one cordis-based session, every further cordis-based session create/resume in that process fails; the only recoveries are (a) restart the host process, or (b) create the second session with a non-cordis preset. Sessions are not corrupted — mount-time failure only. Heavy users are hit hardest: because this is the composition-authoring preset, workspaces accumulate many historical cordis sessions, and after any restart whichever mounts first wins while every other cordis session's resume fails until the next restart (#1415).

DeepSeek Harness mechanism: a process-singleton registry and standing mounts that never unmount

In one sentence: writing session-lifetime registrations into a process-lifetime container guarantees first-come, permanently-owns. Layer by layer:

  1. The registry is a process singleton: CordisInspectRegistryService is created by DynamicCordisRunnerService on the host plane (new CordisInspectRegistryService(ctx), service key cordisInspect, in @deepseek-ai/dsh-cordis-host-runner). Its register() throws Host Cordis inspect provider "<id>" is already registered when the provider id already exists in its Map (#1415).
  2. Every preset mount registers four ids unconditionally: @deepseek-ai/dsh-tool-cordis registers the first-party Host inspect providers in apply() with no condition:
js
for (const provider of hostInspectProviders(ctx))
  ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`);

Because the registry is a process singleton, the vanilla package can be mounted only once per process. Two presets that both carry a tool-cordis row (the shipped cordis preset and any locally authored copy) collide: whichever mounts second throws, the whole preset mount fails, and the session resume fails with it. The failure is order-dependent: the first mount wins and holds the ids until its fiber is disposed (#1415). 3. Standing mounts are process-lifetime: once a preset's standing mount exists (created by any session creation, selection, or even a cold transcript read via standingKeyFor), it is never disposed while the process lives — afterwards recompose only re-binds, it does not unmount the previous preset. So the first cordis-based preset to mount permanently owns all four provider ids (#4675). 4. The leak only happens on abnormal teardown: an independent verification (against the real packages @deepseek-ai/cordis + @deepseek-ai/dsh-cordis-host-runner 0.1.0-rc.6, with a plugin shaped exactly like dsh-tool-cordis's apply) passed all 7 checks: first mount registers all four providers ✅; a second mount in the same process throws the verbatim error ✅; clean teardown (fiber.dispose()) runs the collected disposers and frees all four ids ✅; re-mount after teardown succeeds (recovery without restart confirmed) ✅; without dispose the ids remain ✅; in that leak state another mount throws the same error ✅; cleanup frees everything ✅. The wiring was confirmed too: ctx.effect(fn) executes fn immediately at mount and collects its return value as the teardown disposer, and register() returns an idempotent disposer that deletes only its own registration (#1415). 5. The recovery path was measured: closing the live session on the locally authored preset (normal teardown) released the four provider ids, and the stuck cordis-preset session then resumed successfully without a process restart — which also confirms that the leak only happens on abnormal teardown (#1415). 6. There is no in-process workaround: someone tried hot-patching the singleton's register() from a dynamic Cordis plugin, but the dynamic-plugin sandbox blocks assignment to host services (the sandbox ctx's set trap throws sandbox ctx is read-only; cannot assign "<prop>" and reports the guard failure to the owning agent), so no in-process workaround exists. Restart, or close-and-reopen in the right order (mount the cordis-preset session before any other tool-cordis session), is the only recovery until the stack is fixed (#1415). 7. The other path to the same error is generation leakage: the source of ensureStanding carries an unimplemented TODO — "reclaim the superseded generation once the last agent joined to it is gone. The subtree is not inert — dsh-skill-filesystem watches its roots — and the settings-page authoring flow turns 'a composition changed' into a per-save event". The pointer is dropped and the next generation mounted, the superseded fiber is never disposed, so its ctx.effect registrations (including the four Host inspect providers) never unwind. So "two presets coexisting" and "hot-reloading one preset" are really the same undecided question: when does a preset's mount end? If the answer is "never" (the status quo), anything written into a process-global registry must be first-come and permanently owned — this is not an oversight in tool-cordis (#4675). 8. Why sharing equivalent registrations is sound: Service, Event, and Builtin never touch the mounting ctx at all — they are pure functions over queryServiceApi / queryEventApi / HOST_BUILTIN_INSPECTION, all generated constants. Only Tool closes over ctx, as ctx.tools.schemas(context.agent), and schemas(scope) filters this.view(scope).visible on one shared registry, keyed by the requesting agent from the query context, not by the mounting fiber. No provider's answer therefore depends on which preset won the registration race — that is what makes sharing correct rather than merely convenient (#4675).

DeepSeek Harness: fixed versions, the correct fix, and coexistence workarounds

The fixes come in three tiers of increasing cost: make registration idempotent with holder semantics, make the error visible, then scope the registry per session or preset. Specifically:

  1. What has been fixed: the original reporter confirmed on 2026-08-21 that "I can now open the conflicted agent session directly, no workaround needed anymore", so the specific case was addressed. But the broader coexistence scenario remains: it reproduces on both 0.1.1-rc.2 and 0.1.2-alpha.1, with the trigger unchanged (a user preset copied from the shipped cordis preset via agentPresets.copy, carrying the tool-cordis row verbatim). Useful scope data: on one machine, of 8 presets only the two containing the tool-cordis row are affected; the other six (standard, ptc, minimal, careful, report, research) mount together without contention — verifiable by calling standingKeyFor on each in a process where another preset is already mounted (#1415, #4675).
  2. A one-line reproduction: no second session and no UI needed. From a process where one tool-cordis preset is already mounted:
js
await ctx.agentPresets.standingKeyFor('cordis')
// → throws: Host Cordis inspect provider "Service" is already registered

The same call on any preset without that row returns normally. This isolates the failure to the second preset's standing mount (a --dump-config run, which composes only the profile layer, exits 0). Exact locations: dsh-tool-cordis/lib/index.js:8523 (the registration loop), dsh-cordis-host-runner/lib/index.js:721 (comment: "Register the process-global Host registry."), and :732 (if (this.providers.has(manifest.id)) throw new Error(…)) (#4675). 3. Minimal usable patch: absorb the duplicate in the registration loop: the four first-party providers are static catalogs whose registrations are interchangeable, so a second registrant can simply reuse the first; only the duplicate-registration case is absorbed, every other error still propagates, and the first registrant's behaviour is unchanged:

js
for (const provider of hostInspectProviders(ctx))
  ctx.effect(() => {
    try {
      return ctx.cordisInspect.register(provider);
    } catch (error) {
      // Another preset already registered this process-global provider:
      // it describes process state, so reuse it and do not remove it.
      if (!String(error?.message ?? "").includes("already registered")) throw error;
      return () => {};
    }
  }, `tool-cordis: inspect ${provider.manifest.id}`);

The trade-off is that the provider now lives until process exit — acceptable here, since "this preset unloaded, so those Services no longer exist" is not a meaningful state. A more thorough route is patching the registry itself for idempotent duplicate registration: @deepseek-ai/dsh-cordis-host-runner/lib/index.js (the bundle entry, where the class is inlined) and the standalone copy at lib/types/inspect-registry.js that deep imports may use — patch both. Windows installs: ~/.dsh/profiles/node_modules/@deepseek-ai/* are junctions to the global npm install directory, so patch the global copy, not the junction. Also note the patch lives in node_modules and is overwritten by an npm update, so re-apply after upgrades (#1415, #4675). 4. The correct shape: one entry plus a holder set — not replacement, and not a refcount: replacement loses on disposal ordering (see the FAQ above), and a numeric refcount is unsafe for the same reasonctx.effect disposers are idempotent by contract, so a counter decremented twice evicts a live survivor. The right shape keeps one entry per id plus a set of opaque holder tokens, and deletes the entry only when the last holder disposes:

ts
const existing = this.providers.get(manifest.id)
if (existing !== undefined && canonicalJson(existing.manifest) !== canonicalJson(manifest)) {
  throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
}
if (existing === undefined) this.providers.set(manifest.id, { ...registration, manifest })
const holders = this.holders.get(manifest.id) ?? new Set<object>()
const token = {}
holders.add(token)
this.holders.set(manifest.id, holders)
return () => {
  const live = this.holders.get(manifest.id)
  if (live === undefined || !live.delete(token)) return
  if (live.size === 0) { this.holders.delete(manifest.id); this.providers.delete(manifest.id) }
}

Two details are load-bearing: holder identity rather than a refcount (deleting an absent set member is already a no-op, so identity buys idempotency for free), and a different manifest under a taken id still throws (that is the collision the message was written for, so the startHostHalf recipe that cordis_stop appends stays meaningful). Comparison uses a key-order-independent serialization, since key order carries no meaning in a manifest. The community branch nokkies/dsh-upstream-patches @ fix/cordis-inspect-provider-sharing implements exactly this shape, based on cd5ef8148 (0.1.2-alpha.1), and applies with git am -3 (#4675). 5. Something that deserves its own fix: surface the discarded error: the backend did produce a complete agent-preset-invalid (preset name, loader entry, package, conflicting object, file path), and the Web client dropped it, leaving only "session creation flicks back to no workspace selected". That discarding is generic, so whatever causes the next mount failure, it will present the same way. Making the error visible is worth more than fixing any single cause — it is what decides whether the next unknown failure costs ten minutes or a whole day (#4675). 6. The more upstream choice: scope the registry per session or preset: the root fix is to instantiate the cordisInspect registry (or at least the host-inspect providers) per preset scope rather than process-globally. A third-party preset plugin, KannaKuron/dsh-ptc-cordis-preset, independently documented the same issue and reached the same conclusion: the host-plane runner's inspect registry throwing on duplicate provider ids is "the sole reason only one cordis-mode session can be opened per process", closing or archiving a session does not unmount the standing mount, so "there is no creator-mode session right now" does not mean the contention is gone, and the proper fix is for upstream to make the runner multi-instance per session. That plugin currently ships a monkey-patch shim that replaces duplicates, which itself confirms the pain is real and widespread (#4675). 7. Worth stating plainly what a fix makes disappear and what it does not: sharing an entry makes the hot-reload error disappear without fixing the generation leak — the new generation joins the leaked generation's entry instead of colliding, so the mount succeeds, while the superseded subtree still holds its watchers. That is still the right trade, but it should be a recorded decision rather than a side effect: the collision is a terrible leak detector (it only fires when a second cordis-based preset exists, so the common single-preset case leaks silently today; and when it fires it does not say "a generation leaked" — it says a preset failed to mount and blocks the user). The generation leak therefore needs tracking on its own merits — the joined-agent count the TODO describes — rather than being left to a symptom that no longer occurs (#4675). 8. Immediate workarounds and a neighbouring problem: until a fix lands, the operational rule is one cordis-based preset session per host process — after a restart, create or select the cordis preset you want first, and do not let any other cordis preset's standing mount trigger ahead of it. A sibling failure at the composition layer deserves checking at the same time: a profile's user patch (cordis.patch.yml) and a promoted bundle layer both inserting the same entry id make the loader's EntryGroup.update throw duplicate loader entry id at boot — same theme, different layer. For installing and removing plugins, prefer DSH Plugin Hub; when you hit a mount-time error, first check our plugin-not-loading write-up to tell a scope collision from a dependency problem (#1415).

DSH plugin troubleshooting notes

Remember first that this is not data corruption and that there is no in-process bypass — it is an order-dependent mount-time collision, and a restart or a correctly ordered close-and-reopen is the only recovery. Nine points to keep in mind when a DeepSeek Harness plugin reports this mount failure:

  1. Not data corruption: a mount-time failure writes nothing to the session log, and the same session resumes cleanly after a restart.
  2. The failure is order-dependent: the first mount wins and holds the four ids until its fiber is disposed (usually meaning the process ends).
  3. Clean shutdown frees the ids: the leak is produced only by abnormal teardown (a turn interrupted by a command error, for instance).
  4. No in-process workaround: the dynamic-plugin sandbox is a read-only façade and will refuse a hot patch of a host service.
  5. Do not use "replace": it swaps a loud failure for silent capability loss, and the victim is the preset that did nothing wrong.
  6. Do not use a bare refcount either: disposers are idempotent and a double decrement evicts a live holder; use token identity.
  7. A different manifest must still throw: sharing applies only to equivalent registrations, otherwise a genuine collision gets swallowed.
  8. Deleting sessions does not help: standing mounts live in memory and are not unmounted when sessions are closed or archived.
  9. The UI may show nothing: the backend has a complete agent-preset-invalid; the frontend may drop it, so check backend logs first.
DSH Plugin Hub installed plugins: install and remove plugins, check versions and sources

Sources: Discussion #1415, Discussion #4675, fix/cordis-inspect-provider-sharing, Discussion #902.

FAQ

Does a DSH plugin error like this mean my session data is corrupted?

No — this is a **mount-time failure in DeepSeek Harness, not data corruption**. A rejected resume writes **nothing** to the session log, because the mount fails before any event is appended. From the user's side the session simply looks dead, yet the transcript holds no trace. After a host restart the very same session resumes cleanly, which confirms the in-process registry state as the sole variable and rules out session files and preset contents (Source: Discussion #1415).

Why does only the second cordis-preset session in DeepSeek Harness collide?

In DeepSeek Harness the collision comes from a **process-global singleton** registry. CordisInspectRegistryService's providers Map throws on a duplicate id, while @deepseek-ai/dsh-tool-cordis unconditionally registers four first-party providers (Service, Event, Builtin, Tool) on every preset mount. On top of that, a preset's standing mount is **process-lifetime**: once created it is never disposed (recompose only re-binds). So the first mount permanently owns all four ids and every later preset carrying the same row fails indefinitely (Source: Discussion #4675).

Would replacing on duplicate let two DSH plugins coexist?

That is a trap in any DSH plugin registry: replacing trades a loud failure for a silent capability loss. Say A mounts and registers Service, B mounts and **replaces** the entry, then B unmounts and its standard "remove if still mine" disposer deletes it — because the entry *is* still B's. A is still mounted, still believes it registered four providers, and now has none: cordis_inspect goes dark for a live preset with no error anywhere. That trades a loud, immediate, greppable failure for a silent, delayed, order-dependent capability loss — and the surviving preset is the one that did nothing wrong (Source: Discussion #4675).

Has DeepSeek Harness fixed this upstream? Do I still need the workaround?

DeepSeek Harness is only partly fixed. The original reporter confirmed on 2026-08-21 that the conflicted agent session could be opened directly with no workaround. But the broader two-cordis-preset coexistence case still reproduces on 0.1.1-rc.2 and 0.1.2-alpha.1 (cross-version confirmation as late as 2026-09-10). The community branch nokkies/dsh-upstream-patches @ fix/cordis-inspect-provider-sharing (based on cd5ef8148 / 0.1.2-alpha.1) applies with git am -3; it lets registrations with **equivalent manifests** share one holder set while a **different manifest** under the same id still fails loud (Source: Discussion #1415, nokkies/dsh-upstream-patches fix branch).

Related Terms

standing mount
A preset's process-level mount instance. It is never unmounted while the process lives: when a preset's file stamp changes, `ensureStanding` merely drops the pointer in the standing map and mounts the next generation, without disposing the superseded fiber — so its `ctx.effect` registrations (including the four Host inspect providers) never unwind.https://github.com/deepseek-ai/deepseek-harness/discussions/4675
inspect provider registry
The process-global Map held by `CordisInspectRegistryService`, keyed by provider id (`Service`, `Event`, `Builtin`, `Tool`). It carries process-level facts rather than session-level facts, which is why 'a second registrant reuses the first' is the semantically correct behaviour.https://github.com/deepseek-ai/deepseek-harness/discussions/4675
holder set
The correct shape for idempotent registration: keep one entry per id plus a set of opaque holder tokens, and delete the entry only when the last holder disposes. Token identity rather than a numeric refcount is required because `ctx.effect` disposers are idempotent by contract, and a counter decremented twice evicts a live survivor.https://github.com/deepseek-ai/deepseek-harness/discussions/4675

Sources