diff --git a/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.i18n.yaml index 0cd6794f90..1a38a6e430 100644 --- a/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md b/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md index be68658cfc..ab88d49e21 100644 --- a/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md +++ b/.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. diff --git a/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md b/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md index 6d73062922..f4a9613b92 100644 --- a/.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md +++ b/.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 可能仍在使用这些文件。仓库作业不得按路径或文件年龄回收共享目录。 diff --git a/scripts/fixtures/python-snapshot-workflow-order.mjs b/scripts/fixtures/python-snapshot-workflow-order.mjs new file mode 100644 index 0000000000..ccef5b8d37 --- /dev/null +++ b/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() + }) +} diff --git a/scripts/python-snapshot-workflow-order.spec.ts b/scripts/python-snapshot-workflow-order.spec.ts new file mode 100644 index 0000000000..1d5d46ac58 --- /dev/null +++ b/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)[] = [] +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() + 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) + }) +}) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 30c8334571..b0e5b65ab0 100644 --- a/scripts/smoke-python-runtime.py +++ b/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": (