Fix workspace instruction lifecycle edge cases

This commit is contained in:
Yichen Jiang
2026-07-13 20:39:32 +08:00
parent a0e917ffe3
commit c2f2740a3e
10 changed files with 431 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs-local
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eight `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -13,7 +13,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## Behavior
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat`** — returns `FsInfo` (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).

View File

@@ -117,6 +117,7 @@ export class LocalFileSystem extends FileSystem {
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
const info = await probe(target.targetKey)
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
if (!info) return undefined
return { version: info.version, type: info.type, size: info.size }
}
@@ -125,6 +126,7 @@ export class LocalFileSystem extends FileSystem {
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
if (!info) return undefined
return { version: info.version, type: info.type, size: info.size }
}

View File

@@ -6,7 +6,7 @@
* `dsh-fs-policy`, so it is not exercised here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -141,6 +141,61 @@ describe('lstat', () => {
})
})
describe('metadata cancellation', () => {
it('rejects stat and lstat when their signals abort while the metadata probes are in flight', async () => {
await writeFile(join(dir, 'slow.txt'), 'hello')
const statStarted = Promise.withResolvers<undefined>()
const statRelease = Promise.withResolvers<undefined>()
const lstatStarted = Promise.withResolvers<undefined>()
const lstatRelease = Promise.withResolvers<undefined>()
let isolatedCtx: Context | undefined
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async stat(path: string) {
statStarted.resolve(undefined)
await statRelease.promise
return actual.stat(path, { bigint: true })
},
async lstat(path: string) {
lstatStarted.resolve(undefined)
await lstatRelease.promise
return actual.lstat(path, { bigint: true })
},
}
})
try {
const { LocalFileSystem: IsolatedLocalFileSystem } = await import('../src/index.ts')
isolatedCtx = new Context()
await isolatedCtx.plugin(IsolatedLocalFileSystem, { cwd: dir })
const isolatedFs = isolatedCtx.fs as InstanceType<typeof IsolatedLocalFileSystem>
const target = await isolatedFs.resolve('slow.txt')
const statController = new AbortController()
const lstatController = new AbortController()
const pendingStat = isolatedFs.stat(target, statController.signal)
const pendingLstat = isolatedFs.lstat('slow.txt', undefined, lstatController.signal)
await Promise.all([statStarted.promise, lstatStarted.promise])
statController.abort()
lstatController.abort()
statRelease.resolve(undefined)
lstatRelease.resolve(undefined)
await expect(pendingStat).rejects.toMatchObject({ code: 'FS_ABORTED' })
await expect(pendingLstat).rejects.toMatchObject({ code: 'FS_ABORTED' })
} finally {
statRelease.resolve(undefined)
lstatRelease.resolve(undefined)
await isolatedCtx?.fiber.dispose()
vi.doUnmock('node:fs/promises')
vi.resetModules()
}
})
})
describe('readText / streamText', () => {
it('reads whole-file text', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')