瀏覽代碼

test(python): synchronize advanced snapshot workflow membership

Tianyi Cui 1 周之前
父節點
當前提交
1ca0df2e24

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.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-06-pr-ci-runner-temporary-storage.md
-2026-09-06-pr-ci-runner-temporary-storage.md: be68658cfc058b569e864c7591535505b978cfe1
-2026-09-06-pr-ci-runner-temporary-storage.zh.md: 6d73062922971637db0ba85ea97881c7ea5bf267
+2026-09-06-pr-ci-runner-temporary-storage.md: ab88d49e21cdd42f4b2e9eb05967c43a7639ad16
+2026-09-06-pr-ci-runner-temporary-storage.zh.md: f4a9613b92530add458067017b81f651367d73ab

+ 2 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md

@@ -30,6 +30,8 @@ The installed-wheel live SDK test externally replaces the created file with a fr
 
 The reference-composer fixture maps the known home-abbreviated workspace display to its existing cwd token and waits for the current exact suggestion set before selecting; neither host paths nor stale suggestions determine its result. The shared browser timezone, Inspector subscription synchronization, and PowerShell completion behavior follow the [existing platform-test decision](2026-09-07-pwsh-ci-observable-completion.md).
 
+The advanced Python snapshot pauses only its matching workflow child’s first pre-step until the parent’s durable workflow membership event is observed. The fixture supports either event-arrival order and cancels pending waits on abort or disposal. This pins the scenario’s cross-session ordering without sorting notifications or changing production scheduling.
+
 ## Alternatives considered
 
 **Delete shared temporary files from a PR job.** Another runner may still own those files. Repository jobs must not reclaim a shared directory by pathname or age.

+ 2 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md

@@ -30,6 +30,8 @@ Headless 的 `session-sandbox-root` 夹具声明 `workspace.parent: outside-temp
 
 Reference-composer 夹具将已知的 home 缩写 workspace 显示映射到既有 cwd token,并在选择前等待当前精确建议集;主机路径或过时建议都不决定测试结果。共享浏览器时区、Inspector 订阅同步及 PowerShell 完成行为遵循[既有平台测试决策](2026-09-07-pwsh-ci-observable-completion.zh.md)。
 
+高级 Python 快照仅暂停其匹配的 workflow 子进程首次 pre-step,直到观察到父 Session 的持久化 workflow 成员事件。夹具支持事件先到或等待先建立两种顺序,并在取消或销毁时结束未完成等待。这固定了场景的跨 Session 顺序,而不排序通知或改变生产调度。
+
 ## 考虑过的替代方案
 
 **由 PR 作业删除共享临时文件。** 其他 runner 可能仍在使用这些文件。仓库作业不得按路径或文件年龄回收共享目录。

+ 47 - 0
scripts/fixtures/python-snapshot-workflow-order.mjs

@@ -0,0 +1,47 @@
+/** Hold the advanced workflow child's first step until its parent records membership. */
+export const name = 'python-snapshot-workflow-order'
+
+/**
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context.
+ * @param {{ parentSessionId: string, prompt: string }} config - Exact advanced scenario identities.
+ */
+export function apply(ctx, config) {
+  const started = new Set()
+  const pending = new Map()
+  let disposed = false
+
+  ctx.effect(() => async () => {
+    disposed = true
+    const waits = [...pending.values()]
+    for (const wait of waits) wait.reject(new Error('workflow snapshot barrier disposed'))
+    await Promise.allSettled(waits.map(wait => wait.done))
+    started.clear()
+  })
+  ctx.on('session/event', (session, event) => {
+    if (disposed || session.id !== config.parentSessionId || event.type !== 'tool-workflow/agent-start') return
+    started.add(event.data.childId)
+    pending.get(event.data.childId)?.resolve()
+  })
+  ctx.on('agent/pre-step', async ({ agent, messages, turn, step, signal }, next) => {
+    if (agent.session.header.parentSession !== config.parentSessionId || turn !== 1 || step !== 1
+      || !messages.some(message => message.content.some(block => block.type === 'text' && block.text === config.prompt))) {
+      return next()
+    }
+    signal.throwIfAborted()
+    if (disposed) throw new Error('workflow snapshot barrier disposed')
+    if (!started.has(agent.id)) {
+      const wait = Promise.withResolvers()
+      const abort = () => { wait.reject(signal.reason) }
+      signal.addEventListener('abort', abort, { once: true })
+      wait.done = wait.promise.finally(() => {
+        signal.removeEventListener('abort', abort)
+        pending.delete(agent.id)
+      })
+      pending.set(agent.id, wait)
+      await wait.done
+    }
+    signal.throwIfAborted()
+    if (disposed) throw new Error('workflow snapshot barrier disposed')
+    return next()
+  })
+}

+ 169 - 0
scripts/python-snapshot-workflow-order.spec.ts

@@ -0,0 +1,169 @@
+import { getEventListeners } from 'node:events'
+import { Context } from '@deepseek-ai/cordis'
+import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
+import { MockAdapter, textResponse } from '../packages/core/agent-loop/tests/mock-adapter.ts'
+import type {} from '@deepseek-ai/dsh-tool-workflow'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+// @ts-expect-error Scenario plugins are runtime JavaScript without declaration artifacts.
+import * as fixtureModule from './fixtures/python-snapshot-workflow-order.mjs'
+
+const config = { parentSessionId: 'advanced-parent', prompt: 'workflow child prompt' }
+const fixture = fixtureModule as unknown as {
+  name: string
+  apply(ctx: Context, config: { parentSessionId: string; prompt: string }): void
+}
+const cleanups: (() => Promise<unknown>)[] = []
+afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
+
+async function harness() {
+  const ctx = new Context()
+  const store = ctx.plugin(SessionStore)
+  await store
+  cleanups.push(() => store.dispose())
+  const fiber = ctx.plugin(fixture, config)
+  await fiber
+  cleanups.push(() => fiber.dispose())
+  const parent = ctx.sessions.create(SessionId(config.parentSessionId))
+  const other = ctx.sessions.create(SessionId('other-parent'))
+  const session = ctx.sessions.create(SessionId('workflow-child'), { meta: { parentSession: parent.id } })
+  // The dispatcher needs only the subject identity; the fixture reads its Session.
+  const agent = { id: session.id, session } as Agent
+  const controller = new AbortController()
+  const messages = [createUserMessage({ content: [{ type: 'text', text: config.prompt }], source: { kind: 'user' } })]
+  const decision: PreStepDecision = { kind: 'enter', messages }
+  const next = vi.fn(async () => decision)
+  const start = (owner = parent, childId = agent.id) => owner.append('tool-workflow/agent-start', {
+    runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
+  })
+  const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
+    turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
+  }, next)
+  return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
+}
+
+describe('advanced Python snapshot workflow ordering', () => {
+  it('blocks a real spawned child before its descriptor and first model request', async () => {
+    const ctx = new Context()
+    const entered = Promise.withResolvers<Agent>()
+    const order: string[] = []
+    const adapter = new MockAdapter([textResponse('child complete')])
+    const assembly = ctx.plugin({
+      name: 'workflow-order-driver-test',
+      async apply(inner: Context) {
+        await mountAgentLoopTestDependencies(inner)
+        await inner.plugin(AgentLoop, { agents: [] })
+        await inner.plugin(SessionProjectionRegistry)
+        await inner.plugin(SubagentRuntime)
+        await inner.plugin(spawn, { providerName: 'spawn' })
+        inner.on('agent/pre-step', ({ agent }, next) => {
+          if (agent.session.header.parentSession === config.parentSessionId) entered.resolve(agent)
+          return next()
+        })
+        inner.on('session/event', (_session, event) => {
+          if (event.type === 'tool-workflow/agent-start' || event.type === 'subagent/descriptor') order.push(event.type)
+        })
+        await inner.plugin(fixture, config)
+      },
+    })
+    cleanups.push(() => assembly.dispose())
+    await assembly
+    ctx.llm.registerAdapter(['mock'], adapter)
+    const parent = await ctx.agentLoop.create(SessionId(config.parentSessionId), { provider: 'mock', model: 'mock' })
+    const run = await ctx.subagents.start('spawn', {
+      parent, prompt: [{ type: 'text', text: config.prompt }], signal: new AbortController().signal,
+    })
+    cleanups.push(() => run.dispose())
+    const child = await entered.promise
+    expect(child.id).toBe(run.id)
+    expect(adapter.requests).toHaveLength(0)
+    expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false)
+    parent.session.append('tool-workflow/agent-start', {
+      runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id,
+    })
+    expect((await run.result).output).toEqual([{ type: 'text', text: 'child complete' }])
+    expect(adapter.requests).toHaveLength(1)
+    expect(order).toEqual(['tool-workflow/agent-start', 'subagent/descriptor'])
+  })
+
+  it('holds the child until the exact parent records the exact member', async () => {
+    const h = await harness()
+    const pending = h.step()
+    expect(h.next).not.toHaveBeenCalled()
+    expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(1)
+    h.start(h.other)
+    h.start(h.parent, SessionId('other-child'))
+    h.parent.append('tool-workflow/run-start', { runId: WorkflowRunId('run'), name: 'workflow' })
+    await Promise.resolve()
+    expect(h.next).not.toHaveBeenCalled()
+    h.start()
+    expect(await pending).toBe(h.decision)
+    expect(h.next).toHaveBeenCalledOnce()
+    expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
+  })
+
+  it('retains a start recorded before the child reaches its first step', async () => {
+    const h = await harness()
+    h.start()
+    expect(await h.step()).toBe(h.decision)
+    expect(h.next).toHaveBeenCalledOnce()
+    expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
+  })
+
+  it.each(['prompt', 'parent', 'turn', 'step'])('does not hold an unrelated %s', async (difference) => {
+    const h = await harness()
+    const overrides = difference === 'prompt' ? { messages: [] }
+      : difference === 'turn' ? { turn: 2 }
+        : difference === 'step' ? { step: 2 } : {}
+    if (difference === 'parent') {
+      const session = h.ctx.sessions.create(SessionId('unrelated-child'), { meta: { parentSession: h.other.id } })
+      Object.assign(h.agent, { session })
+    }
+    expect(await h.step(overrides)).toBe(h.decision)
+    expect(h.next).toHaveBeenCalledOnce()
+  })
+
+  it.each([false, true])('rejects cancellation and detaches the waiter (already aborted: %s)', async (alreadyAborted) => {
+    const h = await harness()
+    const reason = new Error('cancelled child')
+    if (alreadyAborted) h.controller.abort(reason)
+    const pending = h.step()
+    const rejected = expect(pending).rejects.toBe(reason)
+    h.controller.abort(reason)
+    await rejected
+    h.start()
+    expect(h.next).not.toHaveBeenCalled()
+    expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
+  })
+
+  it('does not admit a cancelled child when start and cancellation share a tick', async () => {
+    const h = await harness()
+    const reason = new Error('cancelled after membership')
+    const pending = h.step()
+    const rejected = expect(pending).rejects.toBe(reason)
+    h.start()
+    h.controller.abort(reason)
+    await rejected
+    expect(h.next).not.toHaveBeenCalled()
+    expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
+  })
+
+  it('settles pending waits before disposal completes and removes both listeners', async () => {
+    const h = await harness()
+    const pending = h.step()
+    const rejected = expect(pending).rejects.toThrow('workflow snapshot barrier disposed')
+    await h.fiber.dispose()
+    await rejected
+    h.start()
+    expect(h.next).not.toHaveBeenCalled()
+    expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
+    expect(await h.step()).toBe(h.decision)
+  })
+})

+ 5 - 0
scripts/smoke-python-runtime.py

@@ -1285,6 +1285,11 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
         sessions = dsh_home / "sessions"
         patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions)
         feedback_patch = write_profile_patch(root, "feedback.patch.yml", sessions, [{"insert": [
+            {"id": "snapshot-workflow-order", "name": (
+                Path(__file__).resolve().parent / "fixtures/python-snapshot-workflow-order.mjs"
+            ).as_uri(), "config": {
+                "parentSessionId": SNAPSHOT_SESSION_ID, "prompt": SNAPSHOT_WORKFLOW_CHILD_PROMPT,
+            }},
             {"id": "snapshot-message-feedback", "name": "@deepseek-ai/dsh-message-feedback",
              "config": {"maxNoteBytes": 1024}},
             {"id": "snapshot-feedback-producer", "name": (