mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
fix(agent-instructions): surface root marker stat errors
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/agent-instructions/README.md
|
||||
README.md: d69a989a455512177798446e4a3e07b95b5f44d1
|
||||
README.zh.md: 96a456bd576cacb06382de4b8e34db4b5976327f
|
||||
README.md: 114202cdd7cd4d2fd1d2c82330d4f373a3735b0d
|
||||
README.zh.md: 029f87d3b53317b198c011829faf85b41e32c9b3
|
||||
|
||||
@@ -35,6 +35,8 @@ The first request includes one durable baseline message with the user-global `$D
|
||||
|
||||
The defaults suit a typical checkout: `.git` marks the project root, `AGENTS.md` and `CLAUDE.md` are the base candidates, and `AGENTS.local.md` and `CLAUDE.local.md` are additive local overlays. Only `maxBytes` is required — it caps the complete rendered baseline so each deployment chooses its prompt budget explicitly.
|
||||
|
||||
Root discovery climbs only when a marker probe confirms that the marker is absent. A permission or I/O failure stops discovery and surfaces the host or filesystem-provider error instead of selecting an ancestor project.
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
|
||||
@@ -35,6 +35,8 @@ kind: "package-reference"
|
||||
|
||||
默认设置适合典型检出:`.git` 标记项目根目录,`AGENTS.md` 与 `CLAUDE.md` 是基础候选,`AGENTS.local.md` 与 `CLAUDE.local.md` 是叠加的本地 overlay。只有 `maxBytes` 必填——它限制完整渲染后的基线,让每个部署显式选择自己的提示词预算。
|
||||
|
||||
只有确认项目根标记不存在时,项目根发现才会继续上溯。权限或 I/O 失败会停止发现,并返回 Host 或文件系统提供方的错误,而不会选择祖先项目。
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
|
||||
@@ -95,6 +95,10 @@ function isMissingPathError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
}
|
||||
|
||||
function isMissingProviderPathError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'FS_NOT_FOUND'
|
||||
}
|
||||
|
||||
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
@@ -147,11 +151,10 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: Ab
|
||||
try {
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
return await fileSystem.stat(target, signal) !== undefined
|
||||
} catch {
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
// TODO(root-marker-unavailable): preserve provider failure separately from
|
||||
// absence and stop discovery; continuing upward can cross into an ancestor project.
|
||||
return false
|
||||
if (isMissingProviderPathError(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -159,9 +162,10 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: Ab
|
||||
await stat(path)
|
||||
signal?.throwIfAborted()
|
||||
return true
|
||||
} catch {
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
return false
|
||||
if (isMissingPathError(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +176,7 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: Ab
|
||||
* @param fileSystem - optional provider used instead of host filesystem probes.
|
||||
* @param signal - cancellation for provider and host probes.
|
||||
* @returns the discovered project root, or `cwd` when no marker exists.
|
||||
* @throws the original marker metadata error or cancellation reason when a probe is unavailable.
|
||||
*/
|
||||
export async function findProjectRoot(
|
||||
cwd: string,
|
||||
|
||||
@@ -77,6 +77,7 @@ async function write(path: string, content: string): Promise<void> {
|
||||
|
||||
class RecordingFileSystem extends FileSystem {
|
||||
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
|
||||
missingOnStat = new Set<string>()
|
||||
throwOnStat = new Set<string>()
|
||||
throwOnRead = new Set<string>()
|
||||
omitSizes = new Set<string>()
|
||||
@@ -105,6 +106,9 @@ class RecordingFileSystem extends FileSystem {
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal !== undefined) this.signals.push(signal)
|
||||
signal?.throwIfAborted()
|
||||
if (this.missingOnStat.has(target.targetKey)) {
|
||||
throw Object.assign(new Error(`not found: ${target.displayPath}`), { code: 'FS_NOT_FOUND' })
|
||||
}
|
||||
if (this.throwOnStat.has(target.targetKey)) throw new Error(`stat failed: ${target.displayPath}`)
|
||||
const entry = this.entries.get(target.targetKey)
|
||||
if (entry === undefined) return undefined
|
||||
@@ -2073,6 +2077,30 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('continues provider root discovery when a marker is confirmed absent', async () => {
|
||||
const root = join(await tempRepo(), 'virtual-repo')
|
||||
const cwd = join(root, 'pkg')
|
||||
const home = join(await tempRepo(), 'virtual-home')
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.missingOnStat.add(join(cwd, '.git'))
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider parent rule' })
|
||||
await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = await stubAgent(cwd)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('provider parent rule')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dirname(root), { recursive: true, force: true })
|
||||
await rm(dirname(home), { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the direct provider API usable without an operation signal', async () => {
|
||||
const root = resolve('/virtual/no-signal-repo')
|
||||
const home = resolve('/virtual/no-signal-home')
|
||||
@@ -2291,23 +2319,24 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('treats ctx.fs marker lookup failures as absent root markers', async () => {
|
||||
it('surfaces ctx.fs marker lookup failures instead of crossing into an ancestor project', async () => {
|
||||
const root = await tempRepo()
|
||||
const cwd = join(root, 'pkg')
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.throwOnStat.add(join(root, '.git'))
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
|
||||
fs.throwOnStat.add(join(cwd, '.git'))
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ancestor rule must not load' })
|
||||
await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = await stubAgent(root)
|
||||
const agent = await stubAgent(cwd)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
await expect(composeBaselinePrefix(ctx, agent))
|
||||
.rejects.toThrow(`stat failed: ${join(cwd, '.git')}`)
|
||||
|
||||
expect(derivedText(agent)).toContain('repo rule')
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -2540,6 +2569,42 @@ describe('workspace context request injection', () => {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces host marker metadata failures instead of crossing into an ancestor project', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
const cwd = join(root, 'pkg')
|
||||
const markerPath = join(cwd, '.git')
|
||||
const failure = Object.assign(new Error(`permission denied: ${markerPath}`), {
|
||||
code: 'EACCES',
|
||||
path: markerPath,
|
||||
})
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'ancestor rule must not load')
|
||||
await mkdir(cwd, { recursive: true })
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
stat: async (path: string) => {
|
||||
if (path === markerPath) throw failure
|
||||
return actual.stat(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
|
||||
|
||||
await expect(isolated.loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }))
|
||||
.rejects.toBe(failure)
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
Reference in New Issue
Block a user