Repository navigation
Description
What happened?
After a second dsh host takes over state.lock (the first host's lock went stale — the machine slept, or the process froze, for longer than the 60 s staleMs), the first host still answers write endpoints with ok: true, but save() then silently refuses to write state.json. The change exists only in memory: the window shows the new Bot, a real DSH Session is even created and renamed for it, but after a reload the Bot is gone, leaving an orphaned Session that belongs to no saved Bot. Only the next request is correctly rejected with bot/read-only.
Root cause: ds-bot/src/host/store.js:140 — refresh() returns early for a host that believes it is the writer (if (holder === undefined) return) without re-validating lock.owns(), so after a takeover holder stays undefined. readOnly() (store.js:49-53) therefore keeps returning undefined, the channel gate (channel.js:98-99) lets non-READS endpoints run, and createSession (roster.js:23-29) creates the Session — but save() then refuses through writable()'s lost-lock branch (store.js:153-158) and returns without writing, after the endpoint has already answered ok: true.
Expected: the first request after the takeover should fail with bot/read-only, exactly like the second one does. Basis in the code:
home-lock.js:9: "The token, not the pid, decides ownership: a holder that finds another token has lost the lock." (asserted bytest/home-lock.test.js:54-63, "a holder that finds another token in the file knows it lost the lock")store.js:21-23:holderis "undefined while this host may write" — after a takeover that invariant no longer holds, yet nothing re-checks it before a write endpoint is answered.store.js:154-155: "Lost the lock: retake it when nobody else holds it (its file was deleted), else another host took it over and this one stops writing."channel.js:98-99exists precisely to fail non-READS endpoints withbot/read-onlywhile another host holds the team, androster.js:24-25: "while another dsh holds the team, a new Session would belong to no saved Bot."
Related: #9 (second group, "锁失败后继续写入…") covers the write path when the lock is broken or lost mid-queue (lockBroken fail-open, queued commits, dispose). This one is the acceptance path: the endpoint gate consults a holder flag that refresh() never re-validates, so the answer is ok: true for a write that is then dropped. The fix branch linked in #9 does not touch refresh()/readOnly().
PRs are collaborator-only right now, so happy to be assigned; I can push a fix branch for review in the meantime (approach: in refresh(), when holder === undefined, re-validate lock.owns() before a non-READS endpoint is answered, mirroring what writable() already does on the save path).
This report was prepared with AI assistance (automated source analysis and the scripted repro below); every quoted output comes from that run.
Steps to reproduce
Two dsh hosts share one DSH_HOME (e.g. dsh Desktop and dsh Web on the same machine, home-lock.js:1-3). Host A freezes for over 60 s (machine asleep / long sync block), host B claims the now-stale lock, A wakes up, and its next action is a write endpoint (create a Bot, change settings). A shows the new Bot with no error; after a reload the Bot is gone.
Deterministic script against a source checkout — save as lost-lock-repro.mjs inside ds-bot/:
import { spawn } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, utimesSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import { join } from 'node:path'
import { apply } from './index.js'
import { fakeContext } from './test/fake-ctx.js'
const home = mkdtempSync(join(tmpdir(), 'ds-bot-lost-'))
const lockPath = join(home, 'state.lock')
const statePath = join(home, 'state.json')
const start = () => {
const ctx = fakeContext()
ctx.workspaceRegistry = {
resolveByPath: async () => undefined, create: async p => ({ id: 'w1', path: p }), archiveSession: async () => {}, get: id => ({ id: 'w1' }),
}
ctx.sessionController = {
create: async () => ({ sessionId: `s${Math.random().toString(36).slice(2, 8)}` }),
resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup() {} } }),
selectModel: async () => {},
}
ctx.sessionTitle = { rename: async () => {} }
ctx.sessions = { flush: async () => {} }
let route; const register = ctx.connection.fetch.register
ctx.connection.fetch.register = (o) => { route = o; return register(o) }
apply(ctx, { home })
return { ctx, call: async (e, p) => (await route.fetch(new Request('http://x/api/bot', { method: 'POST', body: JSON.stringify({ endpoint: e, payload: p }) }))).json() }
}
const A = start()
await A.call('state', {})
console.log('1. A is writer; bots in file:', Object.values(JSON.parse(readFileSync(statePath, 'utf8')).bots).map(b => b.name))
// A's lock went stale (machine asleep) and a same-machine dsh took it over (a live pid)
const thief = spawn('sleep', ['10'], { stdio: 'ignore' })
writeFileSync(lockPath, JSON.stringify({ pid: thief.pid, host: hostname(), token: 'stolen-token', since: Date.now() }))
utimesSync(lockPath, new Date(), new Date())
// A wakes up: state poll, then a write endpoint, before any save() has run
const sa = await A.call('state', {})
console.log('2. A state readOnly:', JSON.stringify(sa.value.readOnly))
const made = await A.call('create-bot', { name: 'Phantom', pickModel: true })
console.log('3. A create-bot ok:', made.ok, 'value name:', made.value?.name)
console.log('4. state.json bots now:', Object.values(JSON.parse(readFileSync(statePath, 'utf8')).bots).map(b => b.name), '<- Phantom saved?')
await new Promise(r => setTimeout(r, 100))
console.log('5. state.json bots after save flush:', Object.values(JSON.parse(readFileSync(statePath, 'utf8')).bots).map(b => b.name))
const sa2 = await A.call('state', {})
console.log('6. A state readOnly now:', JSON.stringify(sa2.value.readOnly).slice(0, 60) + '...')
console.log('7. A still shows phantom bot in view:', sa2.value.bots.map(b => b.name).join(','))
const second = await A.call('create-bot', { name: 'Second' })
console.log('8. second create-bot:', second.ok, second.error?.code)
A.ctx.dispose(); thief.kill(); rmSync(home, { recursive: true, force: true })cd ds-bot
DSH_HARNESS=/path/to/deepseek-harness node --import ./test/harness-resolve.mjs ./lost-lock-repro.mjsActual output (verbatim):
1. A is writer; bots in file: [ 'Chief' ]
2. A state readOnly: null
[ds-bot] ds-bot: not saving the Bot team: The Bot team in /var/folders/9d/8rtp59416c760mpq785p9lf00000gp/T/ds-bot-lost-ONbLGO is open in another dsh (Web or Desktop, pid 89562 on bogon). This window shows the team but cannot change it; close the other one and reload.
3. A create-bot ok: true value name: Phantom
4. state.json bots now: [ 'Chief' ] <- Phantom saved?
5. state.json bots after save flush: [ 'Chief' ]
6. A state readOnly now: "The Bot team in /var/folders/9d/8rtp59416c760mpq785p9lf0000...
7. A still shows phantom bot in view: Chief,Phantom
8. second create-bot: false bot/read-only
create-bot answered ok: true and the in-memory view shows Phantom (line 7), but state.json was never written (lines 4-5), and only the second write is rejected as bot/read-only (line 8).
Diagnostics
Source-level repro; the normal Settings → Feedback diagnostics were not available for a checkout run, so the equivalent facts:
- DS Bot: source checkout of
mainatbdc5fda64e2ea1e83a4d146440072a2413025df4 - DSH:
deepseek-harnessatdsh-v0.2.1-alpha.1(the version CONTRIBUTING.md pins); host side only, mock scaffolding, no real model, no API keys - Node
v22.23.2, macOS 15 (darwin 25.6.0, arm64); the affected code is the host side, common to DSH Web and DSH Desktop
How did you install DS Bot?
Git address
Privacy
- I checked that this report has no API keys, tokens or personal data.
Activity
BlueX888 commentedon Oct 12, 2026
Reproduced this. Root cause: ds-bot/src/host/store.js:140 — refresh() returned early for a host whose holder === undefined without re-validating lock.owns(), so after another dsh replaced the lock token, readOnly() kept answering undefined, the channel ran create-bot, and save() then refused through writable()'s lost-lock branch after the endpoint had already answered ok:true.
Minimal reproduction:
Observed output:
Fix is ready on
BlueX888:fix/prep-store-lost-lock-write-dropped-ok-true(commitf12eee78). refresh() now re-validates lock.owns() before it skips the follow and re-claims (warns/adopts only when a former reader takes over, while a holder that re-took its deleted lock keeps writing), so the first write after a takeover fails with bot/read-only instead of ok:true with a dropped save.Verified with:
cd ds-bot && npm test — 313 tests, 0 fail (the 14 web-search cancellations reproduce identically on base bdc5fda); the new regression test 'a host that loses its lock to another dsh refuses changes instead of making a phantom Bot' passes.Happy to open the PR once you confirm the approach or assign this to me.
Fei2411 commentedon Oct 12, 2026
已完成修复