From 107e05e79b4bf9793619bffc2d4cf2f0eef0f48d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 01:35:36 +0800 Subject: [PATCH] fix(workspace-context): recompose the baseline on resume, skip only on remount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mount-local baseline guard was seeded from "a baseline already exists in the log", which a resumed session and a hot plugin remount both satisfy. That made a resume skip its baseline, so offline AGENTS.md edits or removals never reached the first resumed request — violating the documented resume contract. Distinguish the two by agent/session-start: a startup or resume emits it before the first step, while a remount attaches to an already-live session and never witnesses it. Only a remount (no witnessed start, baseline already logged) keeps the single logged baseline and skips; a resume falls through and re-composes from current files. Adds a regression that resumes a session with an offline baseline edit and asserts the fresh baseline reflects it. --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../2026-06-24-workspace-context.zh.md | 2 +- .../context/workspace-context/src/index.ts | 22 +++++++--- .../tests/workspace-context.spec.ts | 41 +++++++++++++++++++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 2bd328a4a6..cbd952f447 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -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 -2026-06-24-workspace-context.md: c1539840b404f8601c4a98917c8e0ca31257f688 -2026-06-24-workspace-context.zh.md: 704a369c38a83c19d5a37faf54eab0c66e0e464f +2026-06-24-workspace-context.md: 9591e1ab169f1883e1831b50308c86e55599296b +2026-06-24-workspace-context.zh.md: 6317ad6e2e6c85768e74b5b2657903723ddf72d3 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index c1539840b4..9591e1ab16 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -32,7 +32,7 @@ At the first `agent/step` of an agent-loop instance, the plugin injects one sour The injection becomes a durable `user/message` with plugin provenance. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. +A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that already holds a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. Only a remount (no witnessed lifecycle start, baseline already logged) keeps the existing baseline and skips; a resume re-composes. The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 704a369c38..6317ad6e2e 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -32,7 +32,7 @@ Status: implemented 该注入成为一条带插件来源的持久 `user/message`。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。 +恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有热重挂(未见证生命周期开始、且日志已有基线)才保留既有基线并跳过;恢复则重新组合。 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index d30a2f8b16..222b91af13 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -50,23 +50,35 @@ export function apply(ctx: Context, config: Config): void { const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() const baselineLoaded = new WeakSet() + // Sessions whose lifecycle start this mount witnessed. A startup or resume + // emits agent/session-start before the first step; a hot remount attaches to + // an already-live session and never sees it. That difference is the only + // reliable way to tell a resumed session (re-compose the baseline from + // current files) from a remount over a live one (keep the single baseline + // already in the log) — the durable log looks identical in both cases. + const lifecycleWitnessed = new WeakSet() const pendingByParent = new Map() + ctx.on('agent/session-start', (agent: Agent) => { + lifecycleWitnessed.add(agent.session) + }) + ctx.on('session/event', (session, event) => { observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) }) ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { if (baselineLoaded.has(agent.session)) return - // The guard is mount-local, but the baseline is durable: a hot remount - // over a live session must fold the already-appended baseline from the - // log instead of injecting a duplicate. - if (agent.session.events.some(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) { + // A baseline already in the log with no witnessed lifecycle start is a hot + // remount over a live session: keep that single baseline and skip. A + // resumed session witnessed its start, so it falls through and re-composes. + if (!lifecycleWitnessed.has(agent.session) + && agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) { baselineLoaded.add(agent.session) return } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3e3285b1de..0ecdf5e0f4 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1005,6 +1005,47 @@ describe('workspace context request injection', () => { } }) + it('recomposes the baseline from current files when a resumed session edited it offline', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'old root rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(ctx, original) + + // Offline edit to the baseline file, then resume on a fresh session whose + // seeded log already carries the original baseline. A resumed session is + // registered after this mount's apply(), so the remount guard never seeds + // it: its first step re-composes a fresh baseline from current files, + // reflecting the offline edit before the first resumed request. The old + // baseline stays in history unmutated (note: resume without mutating an + // earlier history event). + await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') + const resumed = stubAgent(root, [...original.session.events]) + + // Resume announces its lifecycle start before the first step. + agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + await composeBaselinePrefix(ctx, resumed) + + const pluginMessages = resumed.session.events.filter(event => + event.type === 'user/message' && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'workspace-context') + expect(pluginMessages).toHaveLength(2) + const latest = pluginMessages.at(-1) + expect(latest?.type === 'user/message' && blocksText(latest.data.content)) + .toContain('new root rule after offline edit') + const original0 = pluginMessages[0] + expect(original0?.type === 'user/message' && blocksText(original0.data.content)) + .toContain('old root rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('tracks only baseline files that were actually included under the byte budget', async () => { const root = await tempRepo() const home = await tempRepo()