fix(context): preserve committed workspace projections

This commit is contained in:
_Kerman
2026-08-03 12:39:39 +08:00
parent 616ee55372
commit f3460a052f
5 changed files with 57 additions and 21 deletions

View File

@@ -65,10 +65,11 @@ describe('time-context through a real headless cordis.yml', () => {
expect(contextText[0]).toMatch(
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
)
expect(contextText[0]).toMatch(
expect(contextText[0]).toContain('Elapsed since the preceding model-visible message: unavailable.')
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
expect(contextText[1]).toMatch(
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
)
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
const headers = events.filter(event => event.type === 'request/header')
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')

View File

@@ -70,6 +70,11 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const instructionVersions: InstructionVersionCache = new WeakMap()
const projectionLifecycle = new AbortController()
ctx.effect(
() => () =>{ projectionLifecycle.abort(new Error('workspace-context disposed')); },
'workspace-context.projectionLifecycle',
)
// Emit listeners are not awaited, so each projection must compose against the
// inbox produced by earlier file results for the same agent.
const projectionTails = new WeakMap<Agent, Promise<void>>()
@@ -186,13 +191,12 @@ export function apply(ctx: Context, config: Config): void {
const queueProjection = (
agent: Agent,
signal: AbortSignal,
touchedPath: string,
): void => {
const previous = projectionTails.get(agent) ?? Promise.resolve()
const current = previous.then(() => composeAndSync(agent, signal, [], [touchedPath]))
const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath]))
.catch((error: unknown) => {
if (!signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
})
projectionTails.set(agent, current)
void current.then(() => {
@@ -221,6 +225,6 @@ export function apply(ctx: Context, config: Config): void {
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
const ownPath = filePathFromExecution(exec)
if (ownPath === undefined) return
queueProjection(exec.agent, exec.signal, ownPath)
queueProjection(exec.agent, ownPath)
})
}

View File

@@ -2090,10 +2090,8 @@ describe('dynamic nested workspace context injection', () => {
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// Cancellation discards the aborted step's pending context. The next
// successful read discovers and durably injects it once.
expect(contexts).toHaveLength(1)
expect(adapter.requests).toHaveLength(4)
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
.toContain('nested rule survives an aborted tool batch')
} finally {
@@ -2219,6 +2217,34 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('finishes a committed file-result projection after the tool signal ends', 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 package rule')
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const controller = new AbortController()
ctx.emit('tools/result', stubToolExecution({
signal: controller.signal,
callId: CallId('read-before-signal-end'),
name: 'read',
arguments: { file_path: join('pkg', 'file.txt') },
agent,
}), { content: [{ type: 'text', text: 'ok' }], isError: false, value: null })
controller.abort(new Error('tool execution ended'))
expect(blocksText((await workspaceContextOf(agent)).content)).toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads every configured instruction candidate present in a nested scope', async () => {
const root = await tempRepo()
const home = await tempRepo()