1
0
Эх сурвалжийг харах

test(ci): stabilize completion waits and Session fixtures

Yudong 2 долоо хоног өмнө
parent
commit
228f3aef83

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.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 .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
-2026-09-08-ci-readiness-and-completion.md: 8ea0a5892d78eda16e657334dba2af58b6648d09
-2026-09-08-ci-readiness-and-completion.zh.md: 62a4c64081369a20a576805fb8a465bffff2922d
+2026-09-08-ci-readiness-and-completion.md: 01d2d7f91a0ef10e161772d3c398261acc472238
+2026-09-08-ci-readiness-and-completion.zh.md: 584d8ea00c012e197cb75d9fbce03eb2e1fb03a1

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md

@@ -10,6 +10,8 @@ The [empty master PR run](https://github.com/deepseek-harness/deepseek-harness/a
 
 Another [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437) reports a null publint child status and an LSP initialization-marker timeout. Their helpers impose five- and three-second limits inside the lane's 90-second test budget. These cases verify publication contents and cancellation behavior rather than cold-start latency.
 
+The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228) exhausts a one-second registry poll after transport failure. Disconnect cleanup includes cancellation, output draining, persistence, and owner disposal; registry removal alone does not establish complete teardown.
+
 ## Decision
 
 The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first.
@@ -18,6 +20,8 @@ The [desktop transaction test](../../../../apps/desktop/tests/project-manager.sp
 
 The [publint runner tests](../../../../scripts/publint-all.spec.ts) pass the active test budget to their child and check launch errors and termination signals before interpreting its exit code. The [LSP instance test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) uses the same budget for its fixture marker, observes the actual pending `didOpen` write before aborting, and captures the query's rejection before waiting for readiness. Its [server fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts) publishes the marker after pausing stdin. Teardown captures the instance list, Context, and directory before its first await.
 
+The [ACP disconnect tests](../../../../packages/acp/acp/tests/dispose.spec.ts) await the real session handle disposer for both EOF and transport failure. A barrier holds disposal pending while the test checks ownership, then releases it before awaiting completion and checking both registries. Neither case invokes plugin disposal to trigger the behavior under test. The independent teardown hook releases the barrier before disposing the captured Context, including when the test body times out.
+
 The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) owns lifecycle cleanup budgets. The [persistent PowerShell decision](2026-09-07-pwsh-ci-observable-completion.md) owns exact versus inferred terminal readiness; a one-shot process's completion promise has different semantics.
 
 ## Alternatives considered

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md

@@ -10,6 +10,8 @@ Status: implemented
 
 另一次 [Windows coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437)报告了 publint 子进程退出状态为 null,以及 LSP 初始化标记等待超时。对应 helper 在通道的 90 秒测试预算内另设五秒和三秒限制。这些用例验证发布内容与取消行为,不衡量冷启动延迟。
 
+[ACP coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228)在传输失败后耗尽一秒的注册表轮询期限。断连清理包含取消、输出排空、持久化和 owner 处置;仅从注册表移除不能证明完整拆卸已经结束。
+
 ## 决策
 
 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。
@@ -18,6 +20,8 @@ Status: implemented
 
 [publint runner 测试](../../../../scripts/publint-all.spec.ts)将当前测试预算传给子进程,并在解释退出码前检查启动错误和终止信号。[LSP 实例测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)用同一预算等待 fixture 标记,在取消前观察实际尚未完成的 `didOpen` 写入,并在等待就绪前接住查询的 rejection。[服务器 fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts)在暂停 stdin 后发布标记。Teardown 在首次 await 前捕获实例列表、Context 和目录。
 
+[ACP 断连测试](../../../../packages/acp/acp/tests/dispose.spec.ts)在 EOF 和传输失败两种情况下等待真实 Session handle 的 disposer。屏障阻塞处置,供测试检查所有权,然后释放屏障,等待完成并检查两个注册表。两个用例都不调用插件处置来触发待验证行为。独立的 teardown hook 在处置捕获的 Context 前释放屏障,包括测试体超时的情况。
+
 [子 Agent 拆卸决策](2026-09-07-subagent-teardown-test-budgets.zh.md)负责生命周期清理预算。[持久 PowerShell 决策](2026-09-07-pwsh-ci-observable-completion.zh.md)负责精确与推断的终端就绪状态;一次性进程的完成 Promise 具有不同语义。
 
 ## 考虑过的替代方案

+ 2 - 2
apps/web/tests/present.e2e.ts

@@ -16,7 +16,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage } from './support.ts'
 
 const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
-const FIXTURE = join(DIR, 'session.v2.jsonl')
+const FIXTURE = join(DIR, 'session.v3.jsonl')
 const MODE = webSnapshotMode()
 const PROMPT = 'Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. '
   + 'Use bash to run exactly `printf "DELIVERED_REPORT\\n" > report.txt; printf "DELIVERED_NOTE\\n" > 说明.txt`. '
@@ -102,7 +102,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg
         ])
       }
     }
-    expect(events.some(event => event.type === 'tool/code-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true)
+    expect(events.some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true)
     expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true)
   }, 200_000)
 

+ 55 - 27
packages/acp/acp/tests/dispose.spec.ts

@@ -7,10 +7,14 @@ import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
 
 describe('ACP connection ownership', () => {
   let harness: BridgeHarness | undefined
+  let releaseBlockedDisposal: (() => void) | undefined
 
   afterEach(async () => {
-    await harness?.dispose()
+    const bridge = harness
     harness = undefined
+    releaseBlockedDisposal?.()
+    releaseBlockedDisposal = undefined
+    await bridge?.dispose()
   })
 
   it('disposal cancels a running prompt and awaits agent teardown', async () => {
@@ -198,34 +202,58 @@ describe('ACP connection ownership', () => {
     expect(harness.ctx.agents.list()).toHaveLength(0)
   })
 
-  it('a client disconnect disposes every owned session without root-context disposal', async () => {
+  it.each([
+    ['a client disconnect', 'closeClientTransport'],
+    ['a failed client transport', 'abortClientTransport'],
+  ] as const)('%s disposes its session without plugin disposal', async (_name, disconnect) => {
     harness = await makeBridgeHarness({ script: ['hang'] })
-    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
-    const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(SessionId(sessionId))!
-    void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
-    await vi.waitFor(() => { expect(agent.status).toBe('running') })
-
-    await harness.closeClientTransport()
-    await harness.acpFiber.dispose()
-    expect(agent.status).toBe('idle')
-    expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
-    expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
-  })
-
-  it('a failed client transport still disposes every owned session', async () => {
-    harness = await makeBridgeHarness({ script: ['hang'] })
-    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
-    const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(SessionId(sessionId))!
-    void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
-    await vi.waitFor(() => { expect(agent.status).toBe('running') })
-
-    await harness.abortClientTransport()
-    await vi.waitFor(() => {
-      expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
+    const bridge = harness
+    const create = bridge.ctx.agents.create.bind(bridge.ctx.agents)
+    const disposalStarted = Promise.withResolvers<undefined>()
+    const releaseDisposal = Promise.withResolvers<undefined>()
+    releaseBlockedDisposal = () => { releaseDisposal.resolve(undefined) }
+    const disposalCompleted = Promise.withResolvers<undefined>()
+    const createSpy = vi.spyOn(bridge.ctx.agents, 'create').mockImplementation(async (options) => {
+      const handle = await create(options)
+      const dispose = handle.dispose.bind(handle)
+      handle.dispose = () => {
+        const completion = (async () => {
+          disposalStarted.resolve(undefined)
+          await releaseDisposal.promise
+          await dispose()
+          return undefined
+        })()
+        disposalCompleted.resolve(completion)
+        return completion
+      }
+      return handle
     })
-    expect(agent.status).toBe('idle')
+    const running = Promise.withResolvers<undefined>()
+    let stopListening: (() => void) | undefined
+    try {
+      await bridge.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+      const { sessionId } = await bridge.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+      const agent = bridge.ctx.agents.get(SessionId(sessionId))!
+      stopListening = bridge.ctx.on('agent/status', ({ agent: changed, status }) => {
+        if (changed === agent && status === 'running') running.resolve(undefined)
+      })
+      void bridge.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
+      await running.promise
+
+      await bridge[disconnect]()
+      await disposalStarted.promise
+      expect(bridge.ctx.agents.get(SessionId(sessionId))).toBe(agent)
+      expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBe(agent.session)
+      releaseDisposal.resolve(undefined)
+      await disposalCompleted.promise
+      expect(agent.status).toBe('idle')
+      expect(bridge.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
+      expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
+    } finally {
+      releaseDisposal.resolve(undefined)
+      stopListening?.()
+      createSpy.mockRestore()
+    }
   })
 
   it('disconnect and plugin disposal share one quiescence boundary', async () => {

+ 1 - 1
packages/fs/tool-present/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-tool-present",
   "description": "Explicit workspace file delivery declarations for the DeepSeek Harness",
-  "version": "0.1.3-alpha.2",
+  "version": "0.1.5-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 4 - 2
packages/spill/spill-local/tests/spill-local.spec.ts

@@ -267,10 +267,12 @@ describe('startup cleanup sweep', () => {
   it('keeps a file exactly at the boundary (only strictly-older expires)', async () => {
     const dir = sessionDir(root, 'sess-1')
     mkdirSync(dir, { recursive: true })
-    const cutoffMs = Date.now() - 30 * DAY_MS
+    const requestedMs = Date.now() - 30 * DAY_MS
     const boundary = join(dir, 'boundary.txt')
     writeFileSync(boundary, 'x')
-    utimesSync(boundary, cutoffMs / 1000, cutoffMs / 1000)
+    utimesSync(boundary, requestedMs / 1000, requestedMs / 1000)
+    // Filesystems can round timestamps written through utimes.
+    const cutoffMs = statSync(boundary).mtimeMs
     await sweepSpillRoots({ roots: [active(root)], cutoffMs, warn: () => {} })
     expect(existsSync(boundary)).toBe(true)
   })

+ 5 - 0
packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts

@@ -36,6 +36,11 @@ export const expectedUnsupported: Readonly<Partial<Record<string, { sourceVersio
     sourceVersion: 2,
     reason: 'session snapshot line 3: format v2 surface before first step cannot acquire a system head without changing chronology',
   },
+  // This recording contains an event absent from the released V2 event inventory.
+  'snapshots/web/present/session.v2.jsonl': {
+    sourceVersion: 2,
+    reason: 'session snapshot line 21: format v2 to v3 cannot safely transform unclassified event deliverables/presented',
+  },
   'snapshots/web/pwsh-terminal/session.v2.jsonl': {
     sourceVersion: 2,
     reason: 'session snapshot line 3: format v2 surface before first step cannot acquire a system head without changing chronology',

+ 1 - 0
scripts/client-build-environment.client.spec.ts

@@ -72,6 +72,7 @@ function repositoryFixture(version = '1.2.3-rc.4'): string {
   git(fixtureRoot, ['init'])
   git(fixtureRoot, ['config', 'user.name', 'DSH test'])
   git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid'])
+  git(fixtureRoot, ['config', 'commit.gpgsign', 'false'])
   git(fixtureRoot, ['add', 'package.json', 'tracked.txt'])
   git(fixtureRoot, ['commit', '-m', 'fixture'])
   return fixtureRoot

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 14 - 0
snapshots/web/present/session.v3.jsonl


Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно