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
+1 -1
View File
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent), [`workspace-context`](../packages/prompt/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
@@ -14,7 +14,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain
The implementation lives in `packages/prompt/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a prompt/context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability.
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes call `lstat` before `resolve`, so a repository-owned final-component symlink is rejected rather than followed outside the workspace. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A provider exception or disagreement after `lstat` is classified as unavailable and never interpreted as a deletion.
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes call `lstat` before `resolve`, so a repository-owned final-component symlink is rejected rather than followed outside the workspace. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. Once `lstat` identifies a regular-file winner, a provider exception or disagreement during resolve/stat is classified as unavailable: it is neither interpreted as a deletion nor allowed to fall through to a lower-priority candidate.
### File Names And Precedence
@@ -48,7 +48,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells,
Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. Once an equal event appears at or after the pending sequence boundary, the pending entry is removed.
At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `context/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy.
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch.
@@ -60,7 +60,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
`maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded render budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes.
`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap<Session, Map<scope, state>>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Cache transitions for model-visible changes commit only when the corresponding context survives the complete tool-result policy chain.
`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap<Session, Map<scope, state>>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Cache transitions for model-visible changes commit only when the corresponding context survives the complete tool-result policy chain, and are invalidated if that accepted context is later dropped with its aborted step before reaching the log.
## Alternatives considered
+2 -2
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`).
+2
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 }
}
+56 -1
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')
+2 -2
View File
@@ -8,7 +8,7 @@ The baseline is composed once per agent-loop instance on `agent/session-prefix`.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Once `lstat` identifies the winning regular-file candidate, a later resolve/stat failure makes that scope temporarily unavailable instead of falling through to a lower-priority name. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
## Prompt Shape
@@ -46,7 +46,7 @@ The core `context/message` envelope is disabled for these messages because the p
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives first because a later tool aborted the step and the loop discarded its context buffer, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
+73 -30
View File
@@ -8,6 +8,7 @@ import { createReadStream } from 'node:fs'
import { lstat, stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
@@ -63,21 +64,35 @@ export type ScopeInstructionProbe =
| { kind: 'absent' }
| { kind: 'unavailable' }
interface StatFileInfo {
target?: FsTarget
size?: number
version?: FsVersion
}
type StatFileProbe =
| { kind: 'present'; info: StatFileInfo }
| { kind: 'absent' }
| { kind: 'unavailable' }
function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined {
return signal === undefined ? undefined : { signal }
}
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<{ size: number } | undefined> {
function isMissingPathError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
}
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
try {
signal?.throwIfAborted()
const info = await lstat(path)
signal?.throwIfAborted()
if (!info.isFile()) return undefined
return { size: info.size }
} catch {
if (!info.isFile()) return { kind: 'absent' }
return { kind: 'present', info: { size: info.size } }
} catch (error: unknown) {
signal?.throwIfAborted()
// Candidates can disappear while discovery is in progress.
return undefined
return isMissingPathError(error) ? { kind: 'absent' } : { kind: 'unavailable' }
}
}
@@ -85,18 +100,30 @@ async function fsStatFile(
path: string,
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<{ target: FsTarget; size?: number; version: FsVersion } | undefined> {
): Promise<StatFileProbe> {
let pathInfo: FsPathInfo | undefined
try {
const pathInfo = await fileSystem.lstat(path, undefined, signal)
if (pathInfo?.type !== 'file') return undefined
const target = await fileSystem.resolve(path, signalOptions(signal))
const info = await fileSystem.stat(target, signal)
if (info?.type !== 'file') return undefined
return { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } }
pathInfo = await fileSystem.lstat(path, undefined, signal)
signal?.throwIfAborted()
} catch {
signal?.throwIfAborted()
// Provider absence and discovery races are both non-fatal.
return undefined
return { kind: 'unavailable' }
}
if (pathInfo?.type !== 'file') return { kind: 'absent' }
try {
const target = await fileSystem.resolve(path, signalOptions(signal))
signal?.throwIfAborted()
const info = await fileSystem.stat(target, signal)
signal?.throwIfAborted()
if (info?.type !== 'file') return { kind: 'unavailable' }
return {
kind: 'present',
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
}
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
}
@@ -104,7 +131,7 @@ async function statFile(
path: string,
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<{ target?: FsTarget; size?: number; version?: FsVersion } | undefined> {
): Promise<StatFileProbe> {
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
}
@@ -209,13 +236,21 @@ async function firstExistingInstructionFile(
): Promise<DiscoveredInstructionFile | undefined> {
for (const candidate of instructionFileCandidates) {
const path = join(dir, candidate)
const fileInfo = await statFile(path, fileSystem, signal)
if (fileInfo !== undefined) {
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
...fileInfo,
}
const probe = await statFile(path, fileSystem, signal)
switch (probe.kind) {
case 'present':
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
...probe.info,
}
case 'absent':
continue
case 'unavailable':
return undefined
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
return assertNever(probe, 'StatFileProbe')
}
}
return undefined
@@ -235,13 +270,21 @@ async function discoverInstructionFiles(
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobalInfo = await statFile(userGlobal, fileSystem, options.signal)
if (userGlobalInfo !== undefined) {
addFile({
absolutePath: userGlobal,
displayPath: userGlobalDisplayPath(config.dshHome),
...userGlobalInfo,
})
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
switch (userGlobalProbe.kind) {
case 'present':
addFile({
absolutePath: userGlobal,
displayPath: userGlobalDisplayPath(config.dshHome),
...userGlobalProbe.info,
})
break
case 'absent':
case 'unavailable':
break
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
assertNever(userGlobalProbe, 'StatFileProbe')
}
const cwd = resolve(options.cwd)
@@ -21,6 +21,7 @@ import {
commitPendingInstructionContexts,
dynamicInstructionContext,
name,
observeInstructionSessionEvent,
reconcileInstructionContext,
retainedInstructionVersionUpdates,
rollbackPendingInstructionChanges,
@@ -55,6 +56,10 @@ export function apply(ctx: Context, config: Config): void {
versionUpdates: InstructionVersionUpdate[]
}>()
ctx.on('session/event', (session, event) => {
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
})
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
const rest = await next()
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
+73 -2
View File
@@ -6,7 +6,7 @@
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
@@ -36,6 +36,7 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
afterSeq: number
step?: { turn: number; step: number }
}
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
@@ -235,6 +236,71 @@ function pendingChangesFor(
return pending
}
function openStep(session: Session): { turn: number; step: number } | undefined {
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
return boundary?.type === 'step/start' ? boundary.data : undefined
}
function invalidateInstructionVersions(
session: Session,
scopes: readonly string[],
cache: InstructionVersionCache,
): void {
const states = cache.get(session)
if (states === undefined) return
for (const scope of scopes) states.delete(scope)
if (states.size === 0) cache.delete(session)
}
/**
* Settle provisional tool-result state against durable session events.
* A matching context event confirms the transition. If its owning step closes
* first, the loop discarded its context buffer, so both duplicate suppression
* and the metadata fast path must be re-armed for the next successful touch.
* @param session - session whose append-only log emitted `event`.
* @param event - newly committed session event.
* @param pendingBySession - provisional transitions awaiting log confirmation.
* @param versionCache - metadata fast path coupled to those transitions.
*/
export function observeInstructionSessionEvent(
session: Session,
event: SessionEvent,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
): void {
const pending = pendingBySession.get(session)
if (pending === undefined) return
switch (event.type) {
case 'context/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
}
if (pending.size === 0) pendingBySession.delete(session)
return
}
case 'step/end': {
const discardedScopes: string[] = []
for (const [scope, waiting] of pending) {
const step = waiting.step
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
pending.delete(scope)
discardedScopes.push(scope)
}
if (pending.size === 0) pendingBySession.delete(session)
invalidateInstructionVersions(session, discardedScopes, versionCache)
return
}
default:
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
return
}
}
/**
* Commit only workspace contexts that survived the complete tool pipeline.
* The observe-only `tools/result` notification calls this before the loop can
@@ -251,13 +317,18 @@ export function commitPendingInstructionContexts(
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = []
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.meta)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {
pending.set(change.scope, { change, afterSeq: agent.session.seq })
pending.set(change.scope, {
change,
afterSeq: agent.session.seq,
...step === undefined ? {} : { step },
})
committed.push(change)
}
}
@@ -5,10 +5,10 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
@@ -33,9 +33,12 @@ import {
import {
baselineInstructionState,
commitPendingInstructionContexts,
observeInstructionSessionEvent,
rollbackPendingInstructionChanges,
type InstructionVersionCache,
type PendingInstructionChange,
} from '../src/state.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
async function tempRepo(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
@@ -243,6 +246,20 @@ function expectNoDerivedMessages(agent: Agent): void {
}
describe('workspace context instruction discovery', () => {
it('treats ENOTDIR while probing a host candidate as confirmed absence', async () => {
const root = await tempRepo()
const homeFile = join(root, 'not-a-directory')
try {
await writeFile(homeFile, 'file')
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: homeFile })
expect(files).toEqual([])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('loads user-global first, then root-to-cwd workspace instructions using the default candidate order', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1292,6 +1309,30 @@ describe('workspace context request injection', () => {
}
})
it('does not fall through to a lower-priority candidate when the winning provider file becomes unavailable', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
fs.throwOnStat.add(join(root, 'AGENTS.md'))
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'must not bypass AGENTS failure' })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expectNoDerivedMessages(agent)
expect(fs.readTargets).not.toContain(join(root, 'CLAUDE.md'))
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('treats ctx.fs marker lookup failures as absent root markers', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1488,9 +1529,100 @@ describe('workspace context request injection', () => {
await rm(home, { recursive: true, force: true })
}
})
it('does not bypass an unavailable host AGENTS.md with a lower-priority candidate', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'CLAUDE.md'), 'must not bypass unavailable AGENTS')
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
lstat: async (path: string) => {
if (path === join(root, 'AGENTS.md')) {
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
}
return actual.lstat(path)
},
}
})
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
expect(rendered).toBeUndefined()
} 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', () => {
it('re-arms a buffered instruction change when a later tool aborts the step before context append', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested rule survives an aborted tool batch')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const adapter = new MockAdapter([
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('read-before-abort'), name: 'read', arguments: '{"file_path":"pkg/deep/file.txt"}' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('abort-after-read'), name: 'abort_step', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] satisfies StreamChunk[],
toolCallResponse('read-after-abort', 'read', { file_path: 'pkg/deep/file.txt' }),
textResponse('done'),
])
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('workspace-context-abort'), { model: 'mock' }, { cwd: root })
ctx.tools.register(defineTool({
name: 'abort_step',
description: 'Abort the current test step.',
parameters: {},
async execute() {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort')
return [{ type: 'text', text: 'aborted' }]
},
}))
agent.send([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
agent.send([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'context/message')
expect(contexts).toHaveLength(1)
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
.toContain('nested rule survives an aborted tool batch')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('builds persisted digest state without inventing a provider version', () => {
const state = baselineInstructionState([{
absolutePath: '/repo/AGENTS.md',
@@ -2631,6 +2763,84 @@ describe('dynamic nested workspace context injection', () => {
})
describe('workspace context pending state', () => {
it('leaves pending transitions from other or untracked steps untouched', () => {
const agent = stubAgent('/')
const change = (scope: string) => ({
action: 'set' as const, scope, path: `${scope}/AGENTS.md`, digest: scope,
})
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>([[
agent.session,
new Map([
['untracked', { change: change('untracked'), afterSeq: 0 }],
['other-turn', { change: change('other-turn'), afterSeq: 0, step: { turn: 2, step: 1 } }],
['other-step', { change: change('other-step'), afterSeq: 0, step: { turn: 1, step: 2 } }],
['current', { change: change('current'), afterSeq: 0, step: { turn: 1, step: 1 } }],
]),
]])
const versions: InstructionVersionCache = new WeakMap()
const ended = agent.session.append('step/end', { turn: 1, step: 1 })
observeInstructionSessionEvent(agent.session, ended, pending, versions)
expect([...pending.get(agent.session)?.keys() ?? []]).toEqual(['untracked', 'other-turn', 'other-step'])
})
it('confirms a pending transition only when its matching workspace context reaches the log', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
const versions: InstructionVersionCache = new WeakMap()
const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
expect(change).toBeDefined()
versions.set(agent.session, new Map([['pkg', {
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
}]]))
const unrelated = agent.session.append('context/message', {
content: [], source: { kind: 'plugin', plugin: 'other' },
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, unrelated, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const otherContext = workspaceChangeContext('other', 'other')
const otherWorkspaceEvent = agent.session.append('context/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {},
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const context = workspaceChangeContext('pkg', 'one')
const confirmed = agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)
expect(pending.has(agent.session)).toBe(false)
expect(versions.get(agent.session)?.has('pkg')).toBe(true)
})
it('discards pending state and its version fast path when the owning step closes first', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
const versions: InstructionVersionCache = new WeakMap()
agent.session.append('step/start', { turn: 1, step: 1 })
commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
versions.set(agent.session, new Map([['pkg', {
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
}]]))
const ended = agent.session.append('step/end', { turn: 1, step: 1 })
observeInstructionSessionEvent(agent.session, ended, pending, versions)
expect(pending.has(agent.session)).toBe(false)
expect(versions.has(agent.session)).toBe(false)
})
it('rolls back only the exact current transition and releases empty session state', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()