From 9bab3c3d44e4366f61ceac2a8176cd0e8faf0b8f Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:03:57 +0800 Subject: [PATCH 01/23] feat(serve): expose workflow tasks and controls --- packages/acp-bridge/src/bridge.test.ts | 21 +- packages/acp-bridge/src/bridge.ts | 23 +- packages/acp-bridge/src/bridgeTypes.ts | 27 +- packages/acp-bridge/src/status.ts | 124 ++- .../cli/src/acp-integration/acpAgent.test.ts | 712 ++++++++++++++++-- packages/cli/src/acp-integration/acpAgent.ts | 207 ++++- .../acp-integration/acpAgent.worktree.test.ts | 1 + .../session/Session.review-lease.test.ts | 6 + .../acp-integration/session/Session.test.ts | 196 +++++ .../src/acp-integration/session/Session.ts | 75 +- .../session/Session.worktree.test.ts | 6 + .../session/tasksSnapshot.test.ts | 302 +++++++- .../acp-integration/session/tasksSnapshot.ts | 99 +++ packages/cli/src/serve/acp-http/dispatch.ts | 90 ++- .../cli/src/serve/acp-http/transport.test.ts | 100 +++ .../serve/multi-workspace-sessions.test.ts | 29 +- packages/cli/src/serve/routes/capabilities.ts | 23 +- packages/cli/src/serve/routes/session.ts | 76 +- packages/cli/src/serve/server.test.ts | 236 +++++- packages/cli/src/serve/server.ts | 1 + .../serve/server/telemetry-catalog.test.ts | 2 +- .../cli/src/serve/server/telemetry.test.ts | 8 +- packages/cli/src/serve/server/telemetry.ts | 6 + packages/cli/src/serve/types.ts | 1 + packages/sdk-typescript/scripts/build.js | 3 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 69 +- .../src/daemon/DaemonSessionClient.ts | 29 +- .../src/daemon/acpRouteTable.ts | 31 +- packages/sdk-typescript/src/daemon/index.ts | 8 + packages/sdk-typescript/src/daemon/types.ts | 133 ++++ packages/sdk-typescript/src/index.ts | 8 + .../test/unit/DaemonClient.test.ts | 13 + .../test/unit/DaemonSessionClient.test.ts | 13 +- .../test/unit/acpRouteTable.test.ts | 42 ++ .../webui/src/daemon/session/actions.test.ts | 86 +++ packages/webui/src/daemon/session/actions.ts | 93 ++- packages/webui/src/daemon/session/types.ts | 24 +- 37 files changed, 2811 insertions(+), 112 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index fe05ae906a2..a7f9b69d94f 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -2709,7 +2709,9 @@ describe('createAcpSessionBridge', () => { availableSkills: [], }); await expect( - bridge.getSessionTasksStatus(session.sessionId), + bridge.getSessionTasksStatus(session.sessionId, { + includeWorkflows: true, + }), ).resolves.toMatchObject({ sessionId: session.sessionId, tasks: [], @@ -2733,6 +2735,10 @@ describe('createAcpSessionBridge', () => { 'qwen/status/session/tasks', 'qwen/status/session/lsp', ]); + expect(handles[0]?.agent.extMethodCalls[2]?.params).toMatchObject({ + sessionId: session.sessionId, + includeWorkflows: true, + }); await bridge.shutdown(); }); @@ -21934,6 +21940,19 @@ describe('createAcpSessionBridge', () => { { clientId: 'client-not-issued' }, ), ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.cancelSessionTask(session.sessionId, 'task-1', 'workflow', { + clientId: 'client-not-issued', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.controlSessionWorkflowTask( + session.sessionId, + 'task-1', + 'rerun', + { clientId: 'client-not-issued' }, + ), + ).rejects.toBeInstanceOf(InvalidClientIdError); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 6e08e5cba5f..69958060830 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -77,6 +77,7 @@ import { type ServeSessionContextStatus, type ServeSessionLspStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, type ServeWorkspaceMcpResourcesStatus, type ServeWorkspaceMcpStatus, type ServeWorkspaceMcpToolsStatus, @@ -10046,10 +10047,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, - async getSessionTasksStatus(sessionId) { + async getSessionTasksStatus(sessionId, opts) { return requestSessionStatus( sessionId, SERVE_STATUS_EXT_METHODS.sessionTasks, + { includeWorkflows: opts?.includeWorkflows === true }, ); }, @@ -10064,7 +10066,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return requestSessionTranscriptPage(req); }, - async cancelSessionTask(sessionId, taskId, taskKind) { + async cancelSessionTask(sessionId, taskId, taskKind, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); return requestSessionStatus<{ cancelled: boolean }>( sessionId, SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, @@ -10072,6 +10077,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async controlSessionWorkflowTask(sessionId, taskId, action, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + return requestSessionStatus<{ + changed: boolean; + status?: ServeSessionWorkflowTaskStatus['status']; + taskId?: string; + }>(sessionId, SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + taskId, + action, + }); + }, + async clearSessionGoal(sessionId) { return requestSessionStatus<{ cleared: boolean; condition?: string }>( sessionId, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 48b014d4385..665078c65c2 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -44,6 +44,7 @@ import type { ServeSessionLspStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, + ServeSessionWorkflowTaskStatus, ServeWorkspaceExtensionsStatus, ServeWorkspaceHooksStatus, ServeWorkspaceMcpToolsStatus, @@ -1632,7 +1633,10 @@ export interface AcpSessionBridge { ): Promise; /** Read the live background task snapshot for a live session. */ - getSessionTasksStatus(sessionId: string): Promise; + getSessionTasksStatus( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ): Promise; /** Read sanitized LSP server status for a live session. */ getSessionLspStatus(sessionId: string): Promise; @@ -1650,9 +1654,28 @@ export interface AcpSessionBridge { cancelSessionTask( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', + context?: BridgeClientRequestContext, ): Promise<{ cancelled: boolean }>; + /** Control a run, delete history, or start a saved workflow definition. */ + controlSessionWorkflowTask( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + context?: BridgeClientRequestContext, + ): Promise<{ + changed: boolean; + status?: ServeSessionWorkflowTaskStatus['status']; + taskId?: string; + }>; + /** Clear an active goal in a live session without cancelling the running prompt. */ clearSessionGoal( sessionId: string, diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 51cb8ac6035..a971fa48cb8 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -175,6 +175,7 @@ export const SERVE_CONTROL_EXT_METHODS = { workspaceMemoryDream: 'qwen/control/workspace/memory/dream', // Runtime MCP server mutation ext-methods sessionTaskCancel: 'qwen/control/session/task/cancel', + sessionWorkflowTaskAction: 'qwen/control/session/task/workflow-action', sessionGoalClear: 'qwen/control/session/goal/clear', /** * Read a live session's `/goal` state. The active goal lives only in the @@ -603,6 +604,13 @@ export interface ServeSessionSupportedCommandsStatus { sessionId: string; availableCommands: AvailableCommand[]; availableSkills: string[]; + /** Whether Workflow is available for this session. */ + workflowsEnabled?: boolean; + /** Reusable workflow definitions visible to this session. */ + savedWorkflows?: Array<{ + name: string; + source: 'project' | 'user'; + }>; } export interface ServeLspServerStatus { @@ -714,10 +722,124 @@ export interface ServeSessionMonitorTaskStatus { toolUseId?: string; } +export interface ServeWorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export type ServeWorkflowDispatchStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface ServeWorkflowDispatchStatusEntry { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: ServeWorkflowDispatchStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface ServeWorkflowApprovalStatusEntry { + approvalId: string; + subagentId: string; + name: string; + description: string; + at: number; +} + +interface ServeWorkflowEventBase { + id: string; + at: number; +} + +export type ServeWorkflowEvent = + | (ServeWorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (ServeWorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (ServeWorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (ServeWorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (ServeWorkflowEventBase & { type: 'log'; message: string }) + | (ServeWorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (ServeWorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (ServeWorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +export interface ServeSessionWorkflowTaskStatus { + kind: 'workflow'; + id: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Restored from the project snapshot store; controls are read-only. */ + isHistorical?: boolean; + sourceRunId?: string; + startMode?: 'retry' | 'rerun'; + label: string; + description: string; + status: ServeSessionTaskLifecycleStatus | 'pausing'; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + isBackgrounded: boolean; + currentPhase: string | null; + phaseVisits: ServeWorkflowPhaseVisit[]; + dispatches: ServeWorkflowDispatchStatusEntry[]; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal: number | null; + recentLogs: string[]; + /** Ordered runtime facts; absent for snapshots created before event tracing. */ + events?: ServeWorkflowEvent[]; + pendingApprovalCount: number; + pendingApprovals?: ServeWorkflowApprovalStatusEntry[]; + error?: string; +} + export type ServeSessionTaskStatus = | ServeSessionAgentTaskStatus | ServeSessionShellTaskStatus - | ServeSessionMonitorTaskStatus; + | ServeSessionMonitorTaskStatus + | ServeSessionWorkflowTaskStatus; export interface ServeSessionTasksStatus { v: typeof STATUS_SCHEMA_VERSION; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 04bb5b8dda8..e3cc0a9c066 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -90,6 +90,12 @@ const { mockPreloadContentGenerator } = vi.hoisted(() => ({ mockPreloadContentGenerator: vi.fn().mockResolvedValue(undefined), })); +const { mockListWorkflowSnapshots } = vi.hoisted(() => ({ + mockListWorkflowSnapshots: vi.fn().mockResolvedValue([]), +})); +const { mockListSavedWorkflows } = vi.hoisted(() => ({ + mockListSavedWorkflows: vi.fn().mockResolvedValue([]), +})); const { mockAddDaemonRequestAttribute } = vi.hoisted(() => ({ mockAddDaemonRequestAttribute: vi.fn(), })); @@ -248,6 +254,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ await importOriginal() ).toolResultPartDiagnosticValues, preloadContentGenerator: mockPreloadContentGenerator, + listSavedWorkflows: mockListSavedWorkflows, + listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, extractDaemonTraceContext: mockExtractDaemonTraceContext, withDaemonSpan: mockWithDaemonSpan, @@ -2011,6 +2019,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { dispose: ReturnType; prompt: ReturnType; releaseTodoStopGuardQueuedPromptWait: ReturnType; + deleteWorkflowHistory: ReturnType; isIdle: ReturnType; isTurnIdle: ReturnType; } @@ -3887,6 +3896,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), + isWorkflowsEnabled: vi.fn().mockReturnValue(false), + getBareMode: vi.fn().mockReturnValue(false), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), }; } @@ -4120,50 +4133,67 @@ describe('QwenAgent MCP SSE/HTTP support', () => { vi.mocked(loadCliConfig).mockResolvedValue( innerConfig as unknown as Config, ); - vi.mocked(Session).mockImplementation((createdSessionId, createdConfig) => { - const sessionMock = { - sessionId: createdSessionId, - getId: vi.fn().mockReturnValue(createdSessionId), - getConfig: vi.fn().mockReturnValue(createdConfig), - sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), - replayHistory: vi.fn().mockResolvedValue(undefined), - installRewriter: vi.fn(), - installGoalTerminalObserver: vi.fn(), - startCronScheduler: vi.fn(), - beginClose: vi.fn().mockReturnValue(vi.fn()), - beginCloseIfAvailable: vi.fn().mockReturnValue(vi.fn()), - waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), - waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), - cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), - enqueueBackgroundNotification: vi - .fn() - .mockResolvedValue({ accepted: true }), - enableLiveScreenContext: vi.fn().mockResolvedValue(undefined), - appendLiveConversationTranscript: vi.fn().mockResolvedValue(undefined), - collectActiveWorkHolds: vi.fn().mockReturnValue([]), - assertCanStartTurn: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - emitGoalStatus: vi.fn(), - captureHistorySnapshot: vi - .fn() - .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), - restoreHistory: vi.fn(), - rewindToTurn: vi - .fn() - .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), - beginHistoryMutation: vi.fn().mockImplementation(() => vi.fn()), - getRewindableUserTurnCount: vi.fn().mockReturnValue(1), - clearActiveTodoPlanRevision: vi.fn(), - clearTodoStopGuardTrust: vi.fn(), - hardSuspendTodoStopGuard: vi.fn(), - releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), - isIdle: vi.fn().mockReturnValue(true), - isTurnIdle: vi.fn().mockReturnValue(true), - prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), - }; - lastSessionMock = sessionMock; - return sessionMock as unknown as InstanceType; - }); + vi.mocked(Session).mockImplementation( + ( + createdSessionId, + createdConfig, + _client, + _settings, + _runExclusiveAutomaticHistoryMutation, + _onActiveWorkChanged, + workflowHistory = [], + ) => { + const sessionMock = { + sessionId: createdSessionId, + getId: vi.fn().mockReturnValue(createdSessionId), + getConfig: vi.fn().mockReturnValue(createdConfig), + getWorkflowHistory: vi.fn().mockReturnValue(workflowHistory), + refreshWorkflowHistory: vi.fn(() => + mockListWorkflowSnapshots(createdConfig), + ), + deleteWorkflowHistory: vi.fn().mockResolvedValue(false), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + replayHistory: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + installGoalTerminalObserver: vi.fn(), + startCronScheduler: vi.fn(), + beginClose: vi.fn().mockReturnValue(vi.fn()), + beginCloseIfAvailable: vi.fn().mockReturnValue(vi.fn()), + waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), + waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), + cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), + enqueueBackgroundNotification: vi + .fn() + .mockResolvedValue({ accepted: true }), + enableLiveScreenContext: vi.fn().mockResolvedValue(undefined), + appendLiveConversationTranscript: vi + .fn() + .mockResolvedValue(undefined), + collectActiveWorkHolds: vi.fn().mockReturnValue([]), + assertCanStartTurn: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + emitGoalStatus: vi.fn(), + captureHistorySnapshot: vi + .fn() + .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), + restoreHistory: vi.fn(), + rewindToTurn: vi + .fn() + .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), + beginHistoryMutation: vi.fn().mockImplementation(() => vi.fn()), + getRewindableUserTurnCount: vi.fn().mockReturnValue(1), + clearActiveTodoPlanRevision: vi.fn(), + clearTodoStopGuardTrust: vi.fn(), + hardSuspendTodoStopGuard: vi.fn(), + releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), + isIdle: vi.fn().mockReturnValue(true), + isTurnIdle: vi.fn().mockReturnValue(true), + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + }; + lastSessionMock = sessionMock; + return sessionMock as unknown as InstanceType; + }, + ); return innerConfig; } @@ -7443,6 +7473,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const innerConfig = await setupSessionMocks(sessionId); const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(5_000); Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + list: vi.fn().mockReturnValue([]), + }), getBackgroundTaskRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([ { @@ -7543,6 +7576,19 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ], availableSkills: ['review'], }); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + mockListSavedWorkflows.mockResolvedValueOnce([ + { + name: 'deep-review', + source: 'project', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + { + name: 'release-check', + source: 'user', + scriptPath: '/home/test/.qwen/workflows/release-check.js', + }, + ]); const agentPromise = runAcpAgent( mockConfig, @@ -7600,6 +7646,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], availableSkills: ['review'], + workflowsEnabled: true, + savedWorkflows: [ + { name: 'deep-review', source: 'project' }, + { name: 'release-check', source: 'user' }, + ], }); expect(tasks).toEqual({ v: 1, @@ -7699,6 +7750,86 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('loads persisted workflow snapshots into the session task history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + list: vi.fn().mockReturnValue([]), + }), + }); + const persistedSnapshot = { + runId: 'wf_saved', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + script: 'return 1;', + phases: ['Inspect'], + phaseVisits: [], + dispatches: [], + agentsDispatched: 1, + agentsCompleted: 1, + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + mockListWorkflowSnapshots + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([persistedSnapshot]); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const legacyTasks = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionTasks, + { sessionId }, + ); + const tasks = await agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + includeWorkflows: true, + }); + + expect(legacyTasks).toMatchObject({ sessionId, tasks: [] }); + expect(tasks).toMatchObject({ + sessionId, + tasks: [ + { + kind: 'workflow', + id: 'wf_saved', + label: 'review-and-fix', + status: 'completed', + isHistorical: true, + }, + ], + }); + expect(mockListWorkflowSnapshots).toHaveBeenCalledTimes(2); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext method returns disabled LSP status', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -9592,7 +9723,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { taskId: 'task-1', taskKind: 'invalid', }), - ).rejects.toThrow('taskKind must be "agent", "shell", or "monitor"'); + ).rejects.toThrow( + 'taskKind must be "agent", "shell", "monitor", or "workflow"', + ); mockConnectionState.resolve(); await agentPromise; @@ -9682,6 +9815,495 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('cancels active workflow tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf-1', + runId: 'wf-1', + kind: 'workflow' as const, + status: 'paused' as 'paused' | 'cancelled', + }; + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const cancel = vi.fn(() => { + task.status = 'cancelled'; + }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(task), + getHandle: vi.fn().mockReturnValue({ completion }), + cancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + let settled = false; + const request = agent + .extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-1', + taskKind: 'workflow', + }) + .then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + resolveCompletion(); + await expect(request).resolves.toEqual({ + cancelled: true, + status: 'cancelled', + }); + expect(cancel).toHaveBeenCalledWith('wf-1', expect.any(Number)); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it.each([ + ['workflows are disabled', false, false, false, true], + ['bare mode is active', true, true, false, true], + ['the workspace is untrusted', true, false, true, false], + ])( + 'does not advertise or execute workflow controls when %s', + async ( + _condition, + workflowsEnabled, + bareMode, + folderTrustFeature, + folderTrust, + ) => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(workflowsEnabled); + innerConfig.getBareMode.mockReturnValue(bareMode); + innerConfig.getFolderTrustFeature.mockReturnValue(folderTrustFeature); + innerConfig.getFolderTrust.mockReturnValue(folderTrust); + const get = vi.fn(); + const cancel = vi.fn(); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ get, cancel }), + }); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValueOnce({ + availableCommands: [ + { + name: 'workflows', + description: 'Custom workflows command', + input: null, + _meta: { source: 'skill-dir-command' }, + }, + { name: 'init', description: 'Initialize', input: null }, + ], + availableSkills: [], + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionSupportedCommands, { + sessionId, + }), + ).resolves.toMatchObject({ + availableCommands: workflowsEnabled + ? [{ name: 'init', description: 'Initialize', input: null }] + : [ + { + name: 'workflows', + description: 'Custom workflows command', + input: null, + _meta: { source: 'skill-dir-command' }, + }, + { name: 'init', description: 'Initialize', input: null }, + ], + workflowsEnabled: false, + savedWorkflows: [], + }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-1', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: false, reason: 'disabled' }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'delete-history', + }), + ).resolves.toEqual({ changed: false }); + expect(mockListSavedWorkflows).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(lastSessionMock!.deleteWorkflowHistory).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }, + ); + + it('pauses and resumes a background workflow task', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf-1', + runId: 'wf-1', + kind: 'workflow' as const, + status: 'running' as 'running' | 'pausing' | 'paused', + isBackgrounded: true, + }; + const pause = vi.fn(() => { + task.status = 'pausing'; + return true; + }); + const resume = vi.fn(() => { + task.status = 'running'; + return true; + }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn(() => task), + pause, + resume, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'pause', + }), + ).resolves.toEqual({ changed: true, status: 'pausing' }); + expect(pause).toHaveBeenCalledWith('wf-1'); + + task.status = 'paused'; + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'resume', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(resume).toHaveBeenCalledWith('wf-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('uses the exact run id when a concurrent saved workflow uses the same script', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const tasks: Array<{ + runId: string; + status: 'running'; + scriptPath: string; + }> = []; + const registry = { + get: vi.fn((runId: string) => + tasks.find((candidate) => candidate.runId === runId), + ), + }; + const execute = vi.fn().mockImplementation(async () => { + tasks.push({ + runId: 'wf_concurrent', + status: 'running', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + tasks.push({ + runId: 'wf_5678efab', + status: 'running', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + return { llmContent: 'started', workflowRunId: 'wf_5678efab' }; + }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { build } : undefined, + ), + }), + }); + mockListSavedWorkflows.mockResolvedValueOnce([ + { + name: 'deep-review', + source: 'project', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + ]); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'deep-review', + action: 'run-saved', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: 'wf_5678efab', + }); + expect(build).toHaveBeenCalledWith({ + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + run_in_background: true, + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('retries a failed workflow with its original script, args, and journal', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockImplementation(async () => { + task.status = 'running'; + return { llmContent: 'started' }; + }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { build } : undefined, + ), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_1234abcd', + action: 'retry', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(build).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + resumeFromRunId: task.runId, + run_in_background: true, + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('reruns a terminal workflow from scratch with a new run id', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'rerun everything' }, + }; + const rerun = { + ...task, + id: 'wf_5678efab', + runId: 'wf_5678efab', + status: 'running' as const, + }; + const concurrentRerun = { + ...task, + id: 'wf_concurrent', + runId: 'wf_concurrent', + status: 'running' as const, + }; + const tasks = [task]; + const setLineage = vi.fn( + (runId: string, sourceRunId: string, startMode: 'rerun') => { + const entry = tasks.find((candidate) => candidate.runId === runId); + if (!entry) return false; + Object.assign(entry, { sourceRunId, startMode }); + return true; + }, + ); + const registry = { + get: vi.fn((runId: string) => + tasks.find((candidate) => candidate.runId === runId), + ), + getHandle: vi.fn(() => undefined), + setLineage, + }; + const execute = vi.fn().mockImplementation(async () => { + tasks.push(concurrentRerun, rerun); + return { llmContent: 'started', workflowRunId: rerun.runId }; + }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { build } : undefined, + ), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action: 'rerun', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: rerun.runId, + }); + expect(build).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + run_in_background: true, + }); + expect(execute).toHaveBeenCalledOnce(); + expect(rerun).toMatchObject({ + sourceRunId: task.runId, + startMode: 'rerun', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('deletes a restored workflow from persistent session history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.deleteWorkflowHistory.mockResolvedValueOnce(true); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_abcd', + action: 'delete-history', + }), + ).resolves.toEqual({ changed: true }); + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledWith( + 'wf_abcd', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('returns not_running for stopped task cancellation', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 4448b4c23f8..9349da68aa4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -126,6 +126,10 @@ import { type WorkspaceRememberContextMode, type ChatRecord, type ToolInvocationGuard, + type WorkflowParams, + type WorkflowToolResult, + listSavedWorkflows, + listWorkflowSnapshots, type TurnResultRecordPayload, } from '@qwen-code/qwen-code-core'; import { randomUUID, timingSafeEqual } from 'node:crypto'; @@ -7701,19 +7705,49 @@ class QwenAgent implements Agent { sessionId: string, ): Promise { const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); const { availableCommands, availableSkills } = - await buildAvailableCommandsSnapshot(session.getConfig()); + await buildAvailableCommandsSnapshot(config); + const workflowsEnabled = this.canUseWorkflowControls(config); + const savedWorkflows = workflowsEnabled + ? (await listSavedWorkflows(config)).map(({ name, source }) => ({ + name, + source, + })) + : []; return { v: STATUS_SCHEMA_VERSION, sessionId, - availableCommands, + availableCommands: + workflowsEnabled || !config.isWorkflowsEnabled() + ? availableCommands + : availableCommands.filter((command) => command.name !== 'workflows'), availableSkills: availableSkills ?? [], + workflowsEnabled, + savedWorkflows, }; } - private buildSessionTasksStatus(sessionId: string): ServeSessionTasksStatus { + private canUseWorkflowControls(config: Config): boolean { + return ( + config.isWorkflowsEnabled() && + !config.getBareMode() && + (!config.getFolderTrustFeature() || config.getFolderTrust()) + ); + } + + private async buildSessionTasksStatus( + sessionId: string, + includeWorkflows = false, + ): Promise { const session = this.sessionOrThrow(sessionId); - return buildSessionTasksStatus(sessionId, session.getConfig()); + return buildSessionTasksStatus( + sessionId, + session.getConfig(), + Date.now(), + includeWorkflows ? await session.refreshWorkflowHistory() : [], + { includeWorkflows }, + ); } private buildSessionLspStatus(sessionId: string): ServeSessionLspStatus { @@ -8699,10 +8733,10 @@ class QwenAgent implements Agent { 'Invalid or missing sessionId', ); } - return this.buildSessionTasksStatus(sessionId) as unknown as Record< - string, - unknown - >; + return (await this.buildSessionTasksStatus( + sessionId, + params['includeWorkflows'] === true, + )) as unknown as Record; } case SERVE_STATUS_EXT_METHODS.sessionLspStatus: { const sessionId = params['sessionId']; @@ -10955,11 +10989,12 @@ class QwenAgent implements Agent { if ( taskKind !== 'agent' && taskKind !== 'shell' && - taskKind !== 'monitor' + taskKind !== 'monitor' && + taskKind !== 'workflow' ) { throw RequestError.invalidParams( undefined, - 'taskKind must be "agent", "shell", or "monitor"', + 'taskKind must be "agent", "shell", "monitor", or "workflow"', ); } debugLogger.info( @@ -11020,12 +11055,161 @@ class QwenAgent implements Agent { ); return { cancelled: true, status: task.status }; } + case 'workflow': { + if (!this.canUseWorkflowControls(config)) { + return { cancelled: false, reason: 'disabled' }; + } + const registry = config.getWorkflowRunRegistry(); + const task = registry.get(taskId); + if ( + !task || + (task.status !== 'running' && + task.status !== 'pausing' && + task.status !== 'paused') + ) { + const reason = task ? 'not_running' : 'not_found'; + debugLogger.info( + `sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`, + ); + return { cancelled: false, reason, status: task?.status }; + } + const handle = registry.getHandle(taskId); + registry.cancel(taskId, Date.now()); + if (handle) await handle.completion; + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`, + ); + return { cancelled: true, status: task.status }; + } default: { const exhaustive: never = taskKind; throw new Error(`Unhandled task kind: ${exhaustive}`); } } } + case SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const taskId = params['taskId']; + if (typeof taskId !== 'string' || taskId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing taskId', + ); + } + const action = params['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + throw RequestError.invalidParams( + undefined, + 'action must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + if (!this.canUseWorkflowControls(config)) { + return { changed: false }; + } + if (action === 'delete-history') { + return { changed: await session.deleteWorkflowHistory(taskId) }; + } + const registry = config.getWorkflowRunRegistry(); + if (action === 'run-saved') { + const savedWorkflow = (await listSavedWorkflows(config)).find( + (entry) => entry.name === taskId, + ); + if (!savedWorkflow) return { changed: false }; + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!workflowTool) { + throw RequestError.invalidParams( + undefined, + 'The workflow tool is unavailable; cannot run this saved workflow.', + ); + } + const result = (await workflowTool + .build({ + scriptPath: savedWorkflow.scriptPath, + run_in_background: true, + } satisfies WorkflowParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + const startedTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + return startedTask + ? { + changed: true, + status: startedTask.status, + taskId: startedTask.runId, + } + : { changed: false }; + } + const task = registry.get(taskId); + if (!task) return { changed: false }; + if (action === 'retry' || action === 'rerun') { + const canStart = + action === 'retry' + ? task.status === 'failed' && !registry.getHandle(taskId) + : task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled'; + if (!canStart || !task.script) { + return { changed: false, status: task.status }; + } + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!workflowTool) { + throw RequestError.invalidParams( + undefined, + `The workflow tool is unavailable; cannot ${action} this run.`, + ); + } + const startParams: WorkflowParams = { + script: task.script, + args: task.args, + ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), + run_in_background: true, + }; + const result = (await workflowTool + .build(startParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + if (action === 'rerun') { + const rerunTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + if (rerunTask) { + registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + } + return rerunTask + ? { + changed: true, + status: rerunTask.status, + taskId: rerunTask.runId, + } + : { changed: false, status: task.status }; + } + return { + changed: true, + status: registry.get(taskId)?.status, + }; + } + const changed = + action === 'pause' ? registry.pause(taskId) : registry.resume(taskId); + return { changed, status: task.status }; + } case SERVE_CONTROL_EXT_METHODS.sessionGoalClear: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -12876,7 +13060,7 @@ class QwenAgent implements Agent { ); } options.beforeSessionCreate?.(); - + const workflowHistory = await listWorkflowSnapshots(config); const session = new Session( sessionId, config, @@ -12884,6 +13068,7 @@ class QwenAgent implements Agent { settings, (operation) => this.runExclusiveHistoryMutation(sessionId, operation), () => this.activeWorkReporter?.notifyChanged(), + workflowHistory, ); let published = false; try { diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 27edbebfa6e..96061d34557 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -207,6 +207,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ snapshot: vi.fn(() => ({})), })), restoreWorktreeContext: mockRestoreWorktreeContext, + listWorkflowSnapshots: vi.fn().mockResolvedValue([]), HookEventName: { PreToolUse: 'PreToolUse', PostToolUse: 'PostToolUse', diff --git a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts index 1f7345b2a21..a88d77c4d3a 100644 --- a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts +++ b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts @@ -163,6 +163,12 @@ describe('Session review-worktree lease sweep', () => { clearStatusChangeCallback: vi.fn(), hasRunningEntries: vi.fn().mockReturnValue(false), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setApprovalRequestCallback: vi.fn(), + }), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), // The Session constructor and Session.prompt both reach for the diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 03a9575e9a7..a83e889d8f3 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -74,6 +74,8 @@ const addToolArgumentsAttributesSpy = vi.hoisted(() => vi.fn()); const addToolCallResultAttributesSpy = vi.hoisted(() => vi.fn()); const logLoopDetectedSpy = vi.hoisted(() => vi.fn()); const logRepeatedToolFailureGuardSpy = vi.hoisted(() => vi.fn()); +const deleteWorkflowSnapshotSpy = vi.hoisted(() => vi.fn()); +const listWorkflowSnapshotsSpy = vi.hoisted(() => vi.fn()); const agentTelemetry = vi.hoisted(() => ({ span: {}, getActiveInteractionSpan: vi.fn(), @@ -151,6 +153,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { logRepeatedToolFailureGuardSpy(...args); return actual.logRepeatedToolFailureGuard(...args); }, + deleteWorkflowSnapshot: deleteWorkflowSnapshotSpy, + listWorkflowSnapshots: listWorkflowSnapshotsSpy, // Transparent recording wrapper: records the constructor deps, then behaves // exactly like the real resolver (subclass → instanceof + methods preserved). LoopTickResolver: class extends actual.LoopTickResolver { @@ -483,8 +487,12 @@ describe('Session', () => { getFunctionDeclarationsFiltered: ReturnType; }; let mockWorkflowRunRegistry: { + setCompletionCallback: ReturnType; + setStatusChangeCallback: ReturnType; + clearStatusChangeCallback: ReturnType; setApprovalRequestCallback: ReturnType; resolvePendingApproval: ReturnType; + getHandle: ReturnType; }; let mockGoalRuntime: { getSnapshot: ReturnType; @@ -593,6 +601,10 @@ describe('Session', () => { addToolCallResultAttributesSpy.mockClear(); logLoopDetectedSpy.mockReset(); logRepeatedToolFailureGuardSpy.mockReset(); + deleteWorkflowSnapshotSpy.mockReset(); + deleteWorkflowSnapshotSpy.mockResolvedValue(true); + listWorkflowSnapshotsSpy.mockReset(); + listWorkflowSnapshotsSpy.mockResolvedValue([]); agentTelemetry.getActiveInteractionSpan.mockReset(); agentTelemetry.addAgentInputMessageAttributes.mockReset(); agentTelemetry.captures.length = 0; @@ -689,8 +701,12 @@ describe('Session', () => { ), }; mockWorkflowRunRegistry = { + setCompletionCallback: vi.fn(), + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), resolvePendingApproval: vi.fn().mockResolvedValue(true), + getHandle: vi.fn().mockReturnValue(undefined), }; mockChatRecordingService = { @@ -2182,6 +2198,180 @@ describe('Session', () => { expect(textParts(notificationCall.message)).toContain(reminder); }); + it('delivers background workflow completions through the session queue', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(async () => createEmptyStream()); + const callback = mockWorkflowRunRegistry.setCompletionCallback.mock + .calls[0][0] as ( + displayText: string, + modelText: string, + meta: { + runId: string; + status: 'completed' | 'failed'; + todoWorkChainId?: string; + }, + ) => void; + + callback('Workflow completed.', '', { + runId: 'wf_1234abcd', + status: 'completed', + }); + + await vi.waitFor(() => + expect(mockChatRecordingService.recordNotification).toHaveBeenCalledWith( + [{ text: '' }], + 'Workflow completed.', + expect.objectContaining({ + taskId: 'wf_1234abcd', + status: 'completed', + kind: 'workflow', + }), + ), + ); + }); + + it('adds terminal workflow status changes to the session history cache', () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_saved', + kind: 'workflow', + runId: 'wf_saved', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: ['Inspect'], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 1, + agentsCompleted: 1, + recentLogs: [], + events: [], + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ + runId: 'wf_saved', + description: 'Review and fix', + status: 'completed', + }), + ]); + }); + + it('keeps cached history on disk failure and removes it after deletion', async () => { + session.dispose(); + const onActiveWorkChanged = vi.fn(); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + onActiveWorkChanged, + [ + { + runId: 'wf_abcd', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'failed', + script: 'return 1;', + phases: ['Inspect'], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }, + ], + ); + listWorkflowSnapshotsSpy.mockResolvedValue(session.getWorkflowHistory()); + + deleteWorkflowSnapshotSpy.mockResolvedValueOnce(false); + await expect(session.deleteWorkflowHistory('wf_abcd')).resolves.toBe(false); + expect(session.getWorkflowHistory()).toHaveLength(1); + expect(onActiveWorkChanged).not.toHaveBeenCalled(); + + await expect(session.deleteWorkflowHistory('wf_abcd')).resolves.toBe(true); + + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + 'wf_abcd', + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledTimes(2); + expect(session.getWorkflowHistory()).toEqual([]); + expect(onActiveWorkChanged).toHaveBeenCalledOnce(); + }); + + it('keeps history unchanged when the requested saved run is unknown', async () => { + await expect(session.deleteWorkflowHistory('wf_missing')).resolves.toBe( + false, + ); + + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + + it('waits for an active run owner to finish persistence before deletion', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_pending', + meta: null, + status: 'completed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishPersistence: (() => void) | undefined; + const completion = new Promise((resolve) => { + finishPersistence = resolve; + }); + mockWorkflowRunRegistry.getHandle.mockReturnValue({ completion }); + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await Promise.resolve(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + + finishPersistence?.(); + await expect(deletion).resolves.toBe(true); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + it('does not infer Todo ownership from Todo Stop Guard lineage', async () => { mockChat.sendMessageStream = vi .fn() @@ -28595,6 +28785,12 @@ describe('Session', () => { expect( mockBackgroundShellRegistry.setNotificationCallback, ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.setCompletionCallback, + ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.clearStatusChangeCallback, + ).toHaveBeenCalledWith(expect.any(Function)); }); it('aborts an active notificationAbortController and nulls the reference', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2b4a7d6a417..92918b0d61b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -52,6 +52,8 @@ import type { ChatRecordingService, TurnResultRecordPayload, WorkflowApproval, + WorkflowSnapshot, + WorkflowTask, BranchPoint, } from '@qwen-code/qwen-code-core'; import { @@ -195,6 +197,11 @@ import { toolResultPartDiagnosticValues, getInvocationContext, runWithInvocationContext, + isTerminalWorkflowStatus, + MAX_RETAINED_SNAPSHOTS, + toSnapshot, + deleteWorkflowSnapshot, + listWorkflowSnapshots, truncateNotificationLabel, buildBackgroundEntryLabel, collectSessionTurnState, @@ -1313,7 +1320,7 @@ export interface BackgroundNotificationQueueItem { modelText: string; taskId: string; status: string; - kind: 'agent' | 'monitor' | 'shell'; + kind: 'agent' | 'monitor' | 'shell' | 'workflow'; toolUseId?: string; todoWorkChainId?: string; /** Structured fields for i18n rendering on the frontend. */ @@ -1839,6 +1846,8 @@ export class Session implements SessionContext { /** The exact status-change callback this Session installed, so dispose can * retract its own and nobody else's. */ #statusChangeCallback: (() => void) | undefined; + #workflowStatusChangeCallback: ((entry?: WorkflowTask) => void) | undefined; + private workflowHistory: WorkflowSnapshot[]; #shellStatusChangeCallback: (() => void) | undefined; private readonly workflowApprovalAbortController = new AbortController(); private activeTodoPlanRevision?: { @@ -1895,8 +1904,10 @@ export class Session implements SessionContext { * a full snapshot; the Session itself keeps no reporting state. */ private readonly onActiveWorkChanged?: () => void, + workflowHistory: readonly WorkflowSnapshot[] = [], ) { this.sessionId = id; + this.workflowHistory = [...workflowHistory]; this.runtimeBaseDir = config.storage.getRuntimeBaseDir(); const todoStopGuardEnabled = this.settings.merged.experimental?.todoStopGuard === true && @@ -3083,6 +3094,41 @@ export class Session implements SessionContext { return this.config; } + getWorkflowHistory(): readonly WorkflowSnapshot[] { + return this.workflowHistory; + } + + async refreshWorkflowHistory(): Promise { + this.workflowHistory = await listWorkflowSnapshots(this.config); + return this.workflowHistory; + } + + async deleteWorkflowHistory(runId: string): Promise { + const handle = this.config.getWorkflowRunRegistry().getHandle(runId); + if (handle) await handle.completion; + await this.refreshWorkflowHistory(); + if (!this.workflowHistory.some((item) => item.runId === runId)) { + return false; + } + if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; + this.workflowHistory = this.workflowHistory.filter( + (item) => item.runId !== runId, + ); + this.#activeWorkChanged(); + return true; + } + + #rememberWorkflowHistory(entry: WorkflowTask): void { + if (!isTerminalWorkflowStatus(entry.status)) return; + const snapshot = toSnapshot(entry); + this.workflowHistory = [ + snapshot, + ...this.workflowHistory.filter((item) => item.runId !== entry.runId), + ] + .sort((a, b) => b.startTime - a.startTime) + .slice(0, MAX_RETAINED_SNAPSHOTS); + } + async assertCanStartTurn(): Promise { if (this.closing) { throw RequestError.invalidParams(undefined, 'Session is closing'); @@ -3353,6 +3399,13 @@ export class Session implements SessionContext { shellRegistry.clearStatusChangeCallback(this.#shellStatusChangeCallback); this.#shellStatusChangeCallback = undefined; } + this.config.getWorkflowRunRegistry().setCompletionCallback(undefined); + if (this.#workflowStatusChangeCallback) { + this.config + .getWorkflowRunRegistry() + .clearStatusChangeCallback(this.#workflowStatusChangeCallback); + this.#workflowStatusChangeCallback = undefined; + } this.config.getChatRecordingService()?.setTitleRecordedCallback(undefined); this.unsubscribeChatRecordingFailure?.(); this.unsubscribeChatRecordingFailure = undefined; @@ -7863,6 +7916,26 @@ export class Session implements SessionContext { }); }); + const workflowRegistry = this.config.getWorkflowRunRegistry(); + this.#workflowStatusChangeCallback = (entry) => { + this.#activeWorkChanged(); + if (entry) this.#rememberWorkflowHistory(entry); + }; + workflowRegistry.setStatusChangeCallback( + this.#workflowStatusChangeCallback, + ); + workflowRegistry.setCompletionCallback((displayText, modelText, meta) => { + this.#enqueueBackgroundNotification({ + displayText, + modelText, + taskId: meta.runId, + status: meta.status, + kind: 'workflow', + continuesTodoStopGuardWorkChain: meta.todoWorkChainId !== undefined, + todoWorkChainId: meta.todoWorkChainId, + }); + }); + // Session title recorded (auto-generated after a turn, or an in-process // /rename) → notify attached clients. A title update is NOT an ACP // `SessionUpdate` variant (the external @agentclientprotocol/sdk union diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index a14805aced0..fa0f73d3cdb 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -183,6 +183,12 @@ describe('Session.pendingWorktreeNotice', () => { clearStatusChangeCallback: vi.fn(), hasRunningEntries: vi.fn().mockReturnValue(false), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setApprovalRequestCallback: vi.fn(), + }), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), // The Session constructor and Session.prompt both reach for the diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts index 3d34261e020..afff78b1cf6 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts @@ -5,7 +5,13 @@ */ import { describe, expect, it } from 'vitest'; -import type { AgentTask, Config, MonitorTask } from '@qwen-code/qwen-code-core'; +import type { + AgentTask, + Config, + MonitorTask, + WorkflowSnapshot, + WorkflowTask, +} from '@qwen-code/qwen-code-core'; import { buildSessionTasksStatus } from './tasksSnapshot.js'; import type { ServeSessionAgentTaskStatus } from '@qwen-code/acp-bridge/status'; @@ -25,14 +31,57 @@ function agentTask(overrides: Partial = {}): AgentTask { } as AgentTask; } -function configWith(agents: AgentTask[]): Config { +function configWith( + agents: AgentTask[], + workflows: WorkflowTask[] = [], +): Config { return { getBackgroundTaskRegistry: () => ({ getAll: () => agents }), getBackgroundShellRegistry: () => ({ getAll: () => [] }), getMonitorRegistry: () => ({ getAll: () => [] }), + getWorkflowRunRegistry: () => ({ list: () => workflows }), } as unknown as Config; } +function workflowSnapshot( + overrides: Partial = {}, +): WorkflowSnapshot { + return { + runId: 'wf_saved', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'failed', + script: 'return 1;', + phases: ['Inspect'], + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 500, + endedAt: 900, + }, + ], + dispatches: [], + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 900, + tokenBudgetTotal: 4_000, + perPhaseTokens: [], + recentLogs: [], + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_000, + error: 'Review failed', + }, + ], + startTime: 500, + endTime: 1_000, + ...overrides, + }; +} + function serializedMonitor( monitor: MonitorTask, ): Extract< @@ -43,6 +92,7 @@ function serializedMonitor( getBackgroundTaskRegistry: () => ({ getAll: () => [] }), getBackgroundShellRegistry: () => ({ getAll: () => [] }), getMonitorRegistry: () => ({ getAll: () => [monitor] }), + getWorkflowRunRegistry: () => ({ list: () => [] }), } as unknown as Config; return buildSessionTasksStatus('session-1', config, 2_000).tasks.find( (task) => task.kind === 'monitor', @@ -125,3 +175,251 @@ describe('buildSessionTasksStatus monitor correlation', () => { expect(task.toolUseId).toBe('monitor-call-1'); }); }); + +describe('buildSessionTasksStatus workflow graph', () => { + it('omits workflow tasks unless the caller opts in', () => { + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([]), + 2_000, + [workflowSnapshot()], + ); + + expect(snapshot.tasks).toEqual([]); + }); + + it('exposes phase visits and dispatch dependencies from the workflow registry', () => { + const workflow = { + kind: 'workflow', + id: 'wf_graph', + runId: 'wf_graph', + toolUseId: 'workflow-call-1', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'running', + startTime: 1_000, + isBackgrounded: true, + currentPhase: 'Review', + phases: ['Inspect', 'Review'], + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 1_000, + endedAt: 1_200, + }, + { id: 'phase-2', index: 1, title: 'Review', startedAt: 1_200 }, + ], + currentPhaseVisitId: 'phase-2', + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Scope mapper', + prompt: 'Inspect the repository', + status: 'completed', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + endedAt: 1_100, + }, + { + id: 'dispatch-2', + phaseVisitId: 'phase-2', + label: 'Correctness', + prompt: 'Review correctness', + subagentId: 'correctness-agent-1', + status: 'running', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + startedAt: 1_220, + }, + ], + agentsDispatched: 2, + agentsCompleted: 1, + recentLogs: ['Review started'], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_250, + message: 'Review started', + }, + { + id: 'event-2', + type: 'approval-requested', + at: 1_300, + name: 'write_file', + dispatchId: 'dispatch-2', + }, + ], + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + perPhaseTokens: new Map(), + script: '', + sourceRunId: 'wf_source', + startMode: 'rerun', + pendingApprovals: [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + callId: 'call-1', + name: 'write_file', + description: 'Update the implementation', + confirmationDetails: {} as never, + at: 1_300, + }, + ], + outputOffset: 0, + notified: false, + outputFile: '', + abortController: new AbortController(), + } as WorkflowTask; + + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([], [workflow]), + 2_000, + [], + { includeWorkflows: true }, + ); + const task = snapshot.tasks.find( + (candidate) => candidate.kind === 'workflow', + ); + + expect(task).toMatchObject({ + kind: 'workflow', + id: 'wf_graph', + toolUseId: 'workflow-call-1', + label: 'review-and-fix', + currentPhase: 'Review', + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + sourceRunId: 'wf_source', + startMode: 'rerun', + phaseVisits: [ + { id: 'phase-1', title: 'Inspect' }, + { id: 'phase-2', title: 'Review' }, + ], + dispatches: [ + { id: 'dispatch-1', status: 'completed', dependsOn: [] }, + { + id: 'dispatch-2', + status: 'running', + subagentId: 'correctness-agent-1', + dependsOn: ['dispatch-1'], + }, + ], + pendingApprovalCount: 1, + pendingApprovals: [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + name: 'write_file', + description: 'Update the implementation', + }, + ], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_250, + message: 'Review started', + }, + { + id: 'event-2', + type: 'approval-requested', + at: 1_300, + name: 'write_file', + dispatchId: 'dispatch-2', + }, + ], + }); + }); + + it('restores persisted workflow runs as read-only task history', () => { + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([]), + 2_000, + [workflowSnapshot()], + { includeWorkflows: true }, + ); + + expect(snapshot.tasks).toEqual([ + expect.objectContaining({ + kind: 'workflow', + id: 'wf_saved', + label: 'review-and-fix', + status: 'failed', + runtimeMs: 500, + isHistorical: true, + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 900, + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_000, + error: 'Review failed', + }, + ], + }), + ]); + }); + + it('prefers the in-memory workflow task over a persisted duplicate', () => { + const workflow = { + kind: 'workflow', + id: 'wf_saved', + runId: 'wf_saved', + description: 'Live entry', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + startTime: 500, + endTime: 1_100, + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 3, + agentsCompleted: 3, + recentLogs: [], + events: [], + tokensSpent: 1_200, + tokenBudgetTotal: 4_000, + perPhaseTokens: new Map(), + script: '', + pendingApprovals: [], + outputOffset: 0, + notified: true, + outputFile: '', + abortController: new AbortController(), + } as WorkflowTask; + + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([], [workflow]), + 2_000, + [workflowSnapshot()], + { includeWorkflows: true }, + ); + const workflows = snapshot.tasks.filter( + (candidate) => candidate.kind === 'workflow', + ); + + expect(workflows).toHaveLength(1); + expect(workflows[0]).toMatchObject({ + id: 'wf_saved', + agentsCompleted: 3, + tokensSpent: 1_200, + }); + expect(workflows[0]).not.toHaveProperty('isHistorical'); + }); +}); diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts index 5b41a532d95..bd5c43a132a 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -10,6 +10,8 @@ import { type Config, type MonitorTask, type ShellTask, + type WorkflowSnapshot, + type WorkflowTask, } from '@qwen-code/qwen-code-core'; import { STATUS_SCHEMA_VERSION, @@ -18,6 +20,7 @@ import { type ServeSessionShellTaskStatus, type ServeSessionTaskStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, } from '@qwen-code/acp-bridge/status'; function runtimeMs( @@ -122,11 +125,99 @@ function serializeMonitorTask( }; } +function serializeWorkflowTask( + entry: WorkflowTask, + now: number, +): ServeSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: entry.runId, + ...optionalField('toolUseId', entry.toolUseId), + ...optionalField('sourceRunId', entry.sourceRunId), + ...optionalField('startMode', entry.startMode), + label: entry.meta?.name ?? entry.description ?? entry.runId, + description: entry.meta?.description ?? entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + ...optionalField('endTime', entry.endTime), + isBackgrounded: entry.isBackgrounded === true, + currentPhase: entry.currentPhase, + phaseVisits: entry.phaseVisits.map((visit) => ({ ...visit })), + dispatches: entry.dispatches.map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), + agentsDispatched: entry.agentsDispatched, + agentsCompleted: entry.agentsCompleted, + tokensSpent: entry.tokensSpent, + tokenBudgetTotal: entry.tokenBudgetTotal, + recentLogs: [...entry.recentLogs], + events: entry.events.map((event) => ({ ...event })), + pendingApprovalCount: entry.pendingApprovals.length, + pendingApprovals: entry.pendingApprovals.map((approval) => ({ + approvalId: approval.approvalId, + subagentId: approval.subagentId, + name: approval.name, + description: approval.description, + at: approval.at, + })), + ...optionalField('error', entry.error), + }; +} + +function serializeWorkflowSnapshot( + snapshot: WorkflowSnapshot, +): ServeSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: snapshot.runId, + isHistorical: true, + ...optionalField('sourceRunId', snapshot.sourceRunId), + ...optionalField('startMode', snapshot.startMode), + label: snapshot.meta?.name ?? snapshot.description ?? snapshot.runId, + description: + snapshot.meta?.description ?? snapshot.description ?? snapshot.runId, + status: snapshot.status, + startTime: snapshot.startTime, + ...optionalField('endTime', snapshot.endTime), + runtimeMs: runtimeMs(snapshot, snapshot.endTime ?? snapshot.startTime), + isBackgrounded: false, + currentPhase: snapshot.phases.at(-1) ?? null, + phaseVisits: (snapshot.phaseVisits ?? []).map((visit) => ({ ...visit })), + dispatches: (snapshot.dispatches ?? []).map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), + agentsDispatched: snapshot.agentsDispatched, + agentsCompleted: snapshot.agentsCompleted, + tokensSpent: snapshot.tokensSpent, + tokenBudgetTotal: snapshot.tokenBudgetTotal, + recentLogs: [...snapshot.recentLogs], + ...optionalField( + 'events', + snapshot.events?.map((event) => ({ ...event })), + ), + pendingApprovalCount: 0, + ...optionalField('error', snapshot.error), + }; +} + export function buildSessionTasksStatus( sessionId: string, config: Config, now = Date.now(), + workflowHistory: readonly WorkflowSnapshot[] = [], + options: { includeWorkflows?: boolean } = {}, ): ServeSessionTasksStatus { + const includeWorkflows = options.includeWorkflows === true; + const workflowTasks = includeWorkflows + ? config.getWorkflowRunRegistry().list() + : []; + const inMemoryWorkflowIds = new Set( + workflowTasks.map((entry) => entry.runId), + ); const tasks: ServeSessionTaskStatus[] = [ ...config .getBackgroundTaskRegistry() @@ -140,6 +231,14 @@ export function buildSessionTasksStatus( .getMonitorRegistry() .getAll() .map((entry) => serializeMonitorTask(entry, now)), + ...(includeWorkflows + ? workflowTasks.map((entry) => serializeWorkflowTask(entry, now)) + : []), + ...(includeWorkflows + ? workflowHistory + .filter((snapshot) => !inMemoryWorkflowIds.has(snapshot.runId)) + .map(serializeWorkflowSnapshot) + : []), ].sort((a, b) => a.startTime - b.startTime); return { diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index e753086c0b9..ae505b916a9 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -265,6 +265,8 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/detach`, `${QWEN_METHOD_NS}session/context_usage`, `${QWEN_METHOD_NS}session/tasks`, + `${QWEN_METHOD_NS}session/tasks/cancel`, + `${QWEN_METHOD_NS}session/tasks/workflow_action`, `${QWEN_METHOD_NS}session/lsp`, `${QWEN_METHOD_NS}session/artifacts`, `${QWEN_METHOD_NS}session/artifacts/add`, @@ -3467,11 +3469,97 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/tasks`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.getSessionTasksStatus(sessionId); + const result = await this.bridge.getSessionTasksStatus(sessionId, { + includeWorkflows: params['includeWorkflows'] === true, + }); this.replyConn(conn, id, result as unknown); return; } + case `${QWEN_METHOD_NS}session/tasks/cancel`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const taskId = String(params['taskId'] ?? ''); + if (!taskId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`taskId` is required'), + ); + } + return; + } + const kind = params['kind']; + if ( + kind !== 'agent' && + kind !== 'shell' && + kind !== 'monitor' && + kind !== 'workflow' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`kind` must be "agent", "shell", "monitor", or "workflow"', + ), + ); + } + return; + } + const result = await this.bridge.cancelSessionTask( + sessionId, + taskId, + kind, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/tasks/workflow_action`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const taskId = String(params['taskId'] ?? ''); + if (!taskId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`taskId` is required'), + ); + } + return; + } + const action = params['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + ), + ); + } + return; + } + const result = await this.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + case `${QWEN_METHOD_NS}session/lsp`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 8a50c7f6ffb..8e991f63f9b 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -494,6 +494,40 @@ class FakeBridge { async getSessionTasksStatus(sessionId: string) { return { sessionId, tasks: [] }; } + lastCancelledTask: + | { + sessionId: string; + taskId: string; + kind: Parameters[2]; + context: Parameters[3]; + } + | undefined; + async cancelSessionTask( + sessionId: string, + taskId: string, + kind: Parameters[2], + context: Parameters[3], + ) { + this.lastCancelledTask = { sessionId, taskId, kind, context }; + return { cancelled: true }; + } + lastWorkflowAction: + | { + sessionId: string; + taskId: string; + action: Parameters[2]; + context: Parameters[3]; + } + | undefined; + async controlSessionWorkflowTask( + sessionId: string, + taskId: string, + action: Parameters[2], + context: Parameters[3], + ) { + this.lastWorkflowAction = { sessionId, taskId, action, context }; + return { changed: true, status: 'running' as const }; + } async getSessionLspStatus(sessionId: string) { return { v: 1, @@ -7520,6 +7554,72 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/session/tasks/cancel forwards the task and trusted client context', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 58, + method: '_qwen/session/tasks/cancel', + params: { + sessionId: 'sess-1', + taskId: 'workflow-1', + kind: 'workflow', + clientId: 'forged-client', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ result: { cancelled: true } }); + expect(bridge.lastCancelledTask).toEqual({ + sessionId: 'sess-1', + taskId: 'workflow-1', + kind: 'workflow', + context: { clientId: 'client-1', fromLoopback: true }, + }); + }); + + it('_qwen/session/tasks/workflow_action forwards the action result and trusted client context', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 59, + method: '_qwen/session/tasks/workflow_action', + params: { + sessionId: 'sess-1', + taskId: 'workflow-1', + action: 'retry', + clientId: 'forged-client', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { changed: true, status: 'running' }, + }); + expect(bridge.lastWorkflowAction).toEqual({ + sessionId: 'sess-1', + taskId: 'workflow-1', + action: 'retry', + context: { clientId: 'client-1', fromLoopback: true }, + }); + }); + it('_qwen/session/lsp returns status', async () => { const connId = await initialize(); const streamRes = openStream(connId); diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index a601a7b1091..539cc33b9c3 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -139,7 +139,7 @@ interface FakeBridge extends AcpSessionBridge { readonly taskCancelCalls: Array<{ sessionId: string; taskId: string; - taskKind: 'agent' | 'shell' | 'monitor'; + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow'; }>; readonly goalClearCalls: string[]; readonly continueCalls: Array<{ @@ -698,11 +698,27 @@ function makeBridge( async cancelSessionTask( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', ) { taskCancelCalls.push({ sessionId, taskId, taskKind }); return { cancelled: workspaceCwd === SECONDARY_CWD }; }, + async controlSessionWorkflowTask( + _sessionId: string, + _taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + ) { + return { + changed: workspaceCwd === SECONDARY_CWD, + status: action === 'pause' ? 'pausing' : 'running', + }; + }, async clearSessionGoal(sessionId: string) { goalClearCalls.push(sessionId); return { @@ -1117,13 +1133,20 @@ describe('multi-workspace session dispatch', () => { expect(res.body.features).toContain('workspace_archived_session_export'); expect(res.body.features).toContain('workspace_display_name'); expect(res.body.workspaces).toEqual([ - { id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true }, + { + id: 'primary-id', + cwd: PRIMARY_CWD, + primary: true, + trusted: true, + workflowsEnabled: false, + }, { id: 'secondary-id', cwd: SECONDARY_CWD, displayName: 'Secondary workspace', primary: false, trusted: true, + workflowsEnabled: false, }, ]); expect(res.body.limits.maxSessionsPerWorkspace).toBe(32); diff --git a/packages/cli/src/serve/routes/capabilities.ts b/packages/cli/src/serve/routes/capabilities.ts index 90116bf7153..96de88fbe6d 100644 --- a/packages/cli/src/serve/routes/capabilities.ts +++ b/packages/cli/src/serve/routes/capabilities.ts @@ -18,7 +18,10 @@ import { type CapabilitiesEnvelope, type ServeOptions, } from '../types.js'; -import type { WorkspaceRegistry } from '../workspace-registry.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; interface RegisterCapabilitiesRoutesDeps { qwenCodeVersion?: string; @@ -32,6 +35,20 @@ interface RegisterCapabilitiesRoutesDeps { maxPendingPromptsPerSession: ServeOptions['maxPendingPromptsPerSession']; sessionRestoreTimeoutMs: number; languageCodes: string[]; + daemonEnv: Readonly; +} + +function workflowsEnabledForRuntime( + runtime: WorkspaceRuntime | undefined, + daemonEnv: Readonly, +): boolean { + if (!runtime || !runtime.trusted) return false; + const env = + runtime.env.mode === 'runtime-overlay' + ? (runtime.env.effectiveEnv ?? {}) + : (runtime.env.effectiveEnv ?? daemonEnv); + if (env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false; + return env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1'; } export function registerCapabilitiesRoutes( @@ -103,6 +120,10 @@ export function registerCapabilitiesRoutes( primary: entry.primary, trusted: entry.state === 'active' && entry.current?.runtime.trusted === true, + workflowsEnabled: workflowsEnabledForRuntime( + entry.state === 'active' ? entry.current?.runtime : undefined, + deps.daemonEnv, + ), ...(runtimeRemoval ? { removable: entry.removable } : {}), ...(entry.current?.runtime.provenance === 'live-conversation' ? { kind: 'live' as const } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 9f75ba40bc8..a0985c3dc9e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -4071,10 +4071,12 @@ export function registerSessionRoutes( '/session/:id/tasks', withOwnerReadSession( 'GET /session/:id/tasks', - async (_req, res, sessionId, runtime) => { - res - .status(200) - .json(await runtime.bridge.getSessionTasksStatus(sessionId)); + async (req, res, sessionId, runtime) => { + res.status(200).json( + await runtime.bridge.getSessionTasksStatus(sessionId, { + includeWorkflows: req.query['includeWorkflows'] === 'true', + }), + ); }, ), ); @@ -4225,16 +4227,72 @@ export function registerSessionRoutes( } const body = safeBody(req); const kind = body['kind']; - if (kind !== 'agent' && kind !== 'shell' && kind !== 'monitor') { - res - .status(400) - .json({ error: '`kind` must be "agent", "shell", or "monitor"' }); + if ( + kind !== 'agent' && + kind !== 'shell' && + kind !== 'monitor' && + kind !== 'workflow' + ) { + res.status(400).json({ + error: '`kind` must be "agent", "shell", "monitor", or "workflow"', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await runtime.bridge.cancelSessionTask( + sessionId, + taskId, + kind, + clientId !== undefined ? { clientId } : undefined, + ), + ); + }, + ), + ); + + app.post( + '/session/:id/tasks/:taskId/workflow-action', + mutate({ strict: true }), + withOwnerMutableSession( + 'POST /session/:id/tasks/:taskId/workflow-action', + async (req, res, sessionId, runtime) => { + const taskId = req.params['taskId']; + if (!taskId) { + res.status(400).json({ + error: '`taskId` route parameter is required', + }); return; } + const action = safeBody(req)['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + res.status(400).json({ + error: + '`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; res .status(200) .json( - await runtime.bridge.cancelSessionTask(sessionId, taskId, kind), + await runtime.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + clientId !== undefined ? { clientId } : undefined, + ), ); }, ), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 765f44943e6..cccb0702d92 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -902,14 +902,30 @@ interface FakeBridgeOpts { sessionId: string, ) => Promise; sessionStatsImpl?: (sessionId: string) => Promise; - sessionTasksImpl?: (sessionId: string) => Promise; + sessionTasksImpl?: ( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ) => Promise; sessionLspImpl?: (sessionId: string) => Promise; sessionTranscriptImpl?: AcpSessionBridge['getSessionTranscriptPage']; cancelSessionTaskImpl?: ( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', + context?: BridgeClientRequestContext, ) => Promise<{ cancelled: boolean }>; + controlSessionWorkflowTaskImpl?: ( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + context?: BridgeClientRequestContext, + ) => Promise<{ changed: boolean; status?: string; taskId?: string }>; clearSessionGoalImpl?: ( sessionId: string, ) => Promise<{ cleared: boolean; condition?: string }>; @@ -1190,6 +1206,7 @@ interface FakeBridge extends AcpSessionBridge { sessionSupportedCommandsCalls: string[]; sessionStatsCalls: string[]; sessionTasksCalls: string[]; + sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined>; sessionLspCalls: string[]; sessionTranscriptCalls: Array< Parameters[0] @@ -1197,7 +1214,20 @@ interface FakeBridge extends AcpSessionBridge { cancelSessionTaskCalls: Array<{ sessionId: string; taskId: string; - taskKind: 'agent' | 'shell' | 'monitor'; + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow'; + context?: BridgeClientRequestContext; + }>; + controlSessionWorkflowTaskCalls: Array<{ + sessionId: string; + taskId: string; + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved'; + context?: BridgeClientRequestContext; }>; clearSessionGoalCalls: string[]; continueSessionCalls: string[]; @@ -1372,9 +1402,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionSupportedCommandsCalls: string[] = []; const sessionStatsCalls: string[] = []; const sessionTasksCalls: string[] = []; + const sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined> = + []; const sessionLspCalls: string[] = []; const sessionTranscriptCalls: FakeBridge['sessionTranscriptCalls'] = []; const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = []; + const controlSessionWorkflowTaskCalls: FakeBridge['controlSessionWorkflowTaskCalls'] = + []; const clearSessionGoalCalls: string[] = []; const continueSessionCalls: string[] = []; const continueSessionContexts: Array = @@ -1715,6 +1749,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { })); const cancelSessionTaskImpl = opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true })); + const controlSessionWorkflowTaskImpl = + opts.controlSessionWorkflowTaskImpl ?? + (async () => ({ changed: true, status: 'pausing' })); const clearSessionGoalImpl = opts.clearSessionGoalImpl ?? (async () => ({ cleared: true })); const continueSessionImpl = @@ -1954,9 +1991,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionSupportedCommandsCalls, sessionStatsCalls, sessionTasksCalls, + sessionTasksOptions, sessionLspCalls, sessionTranscriptCalls, cancelSessionTaskCalls, + controlSessionWorkflowTaskCalls, clearSessionGoalCalls, continueSessionCalls, continueSessionContexts, @@ -2230,9 +2269,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionStatsCalls.push(sessionId); return sessionStatsImpl(sessionId); }, - async getSessionTasksStatus(sessionId) { + async getSessionTasksStatus(sessionId, opts) { sessionTasksCalls.push(sessionId); - return sessionTasksImpl(sessionId); + sessionTasksOptions.push(opts); + return sessionTasksImpl(sessionId, opts); }, async getSessionLspStatus(sessionId) { sessionLspCalls.push(sessionId); @@ -2242,9 +2282,23 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionTranscriptCalls.push(req); return sessionTranscriptImpl(req); }, - async cancelSessionTask(sessionId, taskId, taskKind) { - cancelSessionTaskCalls.push({ sessionId, taskId, taskKind }); - return cancelSessionTaskImpl(sessionId, taskId, taskKind); + async cancelSessionTask(sessionId, taskId, taskKind, context) { + cancelSessionTaskCalls.push({ + sessionId, + taskId, + taskKind, + ...(context ? { context } : {}), + }); + return cancelSessionTaskImpl(sessionId, taskId, taskKind, context); + }, + async controlSessionWorkflowTask(sessionId, taskId, action, context) { + controlSessionWorkflowTaskCalls.push({ + sessionId, + taskId, + action, + ...(context ? { context } : {}), + }); + return controlSessionWorkflowTaskImpl(sessionId, taskId, action, context); }, async clearSessionGoal(sessionId) { clearSessionGoalCalls.push(sessionId); @@ -3813,6 +3867,71 @@ describe('createServeApp', () => { }); describe('GET /capabilities', () => { + it('advertises workflow availability per workspace before a session exists', async () => { + const primaryBridge = fakeBridge(); + const primary = makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + bridge: primaryBridge, + }); + const secondary: WorkspaceRuntime = { + ...makeWorkspaceRuntimeForTest({ + workspaceId: 'secondary-id', + workspaceCwd: '/workspace/secondary', + primary: false, + bridge: fakeBridge(), + }), + env: { + mode: 'runtime-overlay', + overlayKeys: [ + 'QWEN_CODE_ENABLE_WORKFLOWS', + 'QWEN_CODE_DISABLE_WORKFLOWS', + ], + effectiveEnv: { + QWEN_CODE_ENABLE_WORKFLOWS: '1', + QWEN_CODE_DISABLE_WORKFLOWS: '1', + }, + }, + }; + const untrusted = makeWorkspaceRuntimeForTest({ + workspaceId: 'untrusted-id', + workspaceCwd: '/workspace/untrusted', + primary: false, + bridge: fakeBridge(), + trusted: false, + }); + const app = createServeApp(baseOpts, undefined, { + bridge: primaryBridge, + workspaceRegistry: createWorkspaceRegistry([ + primary, + secondary, + untrusted, + ]), + daemonEnv: { QWEN_CODE_ENABLE_WORKFLOWS: '1' }, + }); + + const response = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(response.status).toBe(200); + expect(response.body.workspaces).toEqual([ + expect.objectContaining({ + id: 'primary-id', + workflowsEnabled: true, + }), + expect.objectContaining({ + id: 'secondary-id', + workflowsEnabled: false, + }), + expect.objectContaining({ + id: 'untrusted-id', + workflowsEnabled: false, + }), + ]); + }); + it('advertises the effective session restore timeout', async () => { const defaultResponse = await request( createServeApp(baseOpts, undefined, { bridge: fakeBridge() }), @@ -3965,6 +4084,7 @@ describe('createServeApp', () => { workspaceRegistry: registry, createWorkspaceRuntime: vi.fn(), workspaceRegistrationStore: {} as unknown as WorkspaceRegistrationStore, + daemonEnv: {}, }); const before = await request(app) @@ -3985,6 +4105,7 @@ describe('createServeApp', () => { cwd: WS_BOUND, primary: true, trusted: true, + workflowsEnabled: false, }, ]); @@ -4045,6 +4166,7 @@ describe('createServeApp', () => { displayName: 'Conversations', primary: false, trusted: true, + workflowsEnabled: false, kind: 'live', }); expect(response.body.features).not.toContain('multi_workspace_sessions'); @@ -8875,6 +8997,9 @@ describe('createServeApp', () => { const tasksRes = await request(app) .get('/session/s-1/tasks') .set('Host', `127.0.0.1:${baseOpts.port}`); + const workflowTasksRes = await request(app) + .get('/session/s-1/tasks?includeWorkflows=true') + .set('Host', `127.0.0.1:${baseOpts.port}`); const lspRes = await request(app) .get('/session/s-1/lsp') .set('Host', `127.0.0.1:${baseOpts.port}`); @@ -8887,12 +9012,18 @@ describe('createServeApp', () => { expect(statsRes.body).toEqual(stats); expect(tasksRes.status).toBe(200); expect(tasksRes.body).toEqual(tasks); + expect(workflowTasksRes.status).toBe(200); + expect(workflowTasksRes.body).toEqual(tasks); expect(lspRes.status).toBe(200); expect(lspRes.body).toEqual(lsp); expect(bridge.sessionContextCalls).toEqual(['s-1']); expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']); expect(bridge.sessionStatsCalls).toEqual(['s-1']); - expect(bridge.sessionTasksCalls).toEqual(['s-1']); + expect(bridge.sessionTasksCalls).toEqual(['s-1', 's-1']); + expect(bridge.sessionTasksOptions).toEqual([ + { includeWorkflows: false }, + { includeWorkflows: true }, + ]); expect(bridge.sessionLspCalls).toEqual(['s-1']); }); @@ -9142,12 +9273,12 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.error).toBe( - '`kind` must be "agent", "shell", or "monitor"', + '`kind` must be "agent", "shell", "monitor", or "workflow"', ); expect(bridge.cancelSessionTaskCalls).toEqual([]); }); - it('cancels a session task through the bridge', async () => { + it('cancels a workflow task through the bridge', async () => { const bridge = fakeBridge({ cancelSessionTaskImpl: async () => ({ cancelled: true }), }); @@ -9162,12 +9293,91 @@ describe('createServeApp', () => { .post('/session/s-1/tasks/task-1/cancel') .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret') - .send({ kind: 'agent' }); + .set('X-Qwen-Client-Id', 'client-1') + .send({ kind: 'workflow' }); expect(res.status).toBe(200); expect(res.body).toEqual({ cancelled: true }); expect(bridge.cancelSessionTaskCalls).toEqual([ - { sessionId: 's-1', taskId: 'task-1', taskKind: 'agent' }, + { + sessionId: 's-1', + taskId: 'task-1', + taskKind: 'workflow', + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('controls live runs, saved definitions, and history through one route', async () => { + const bridge = fakeBridge({ + controlSessionWorkflowTaskImpl: async (_sessionId, _taskId, action) => + action === 'pause' + ? { changed: true, status: 'pausing' } + : { changed: true, status: 'running' }, + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const pauseRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ action: 'pause' }); + const resumeRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'resume' }); + const retryRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'retry' }); + const rerunRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'rerun' }); + const deleteRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'delete-history' }); + const runSavedRes = await request(app) + .post('/session/s-1/tasks/deep-review/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'run-saved' }); + + expect(pauseRes.status).toBe(200); + expect(pauseRes.body).toEqual({ changed: true, status: 'pausing' }); + expect(resumeRes.status).toBe(200); + expect(resumeRes.body).toEqual({ changed: true, status: 'running' }); + expect(retryRes.status).toBe(200); + expect(retryRes.body).toEqual({ changed: true, status: 'running' }); + expect(rerunRes.status).toBe(200); + expect(rerunRes.body).toEqual({ changed: true, status: 'running' }); + expect(deleteRes.status).toBe(200); + expect(deleteRes.body).toEqual({ changed: true, status: 'running' }); + expect(runSavedRes.status).toBe(200); + expect(runSavedRes.body).toEqual({ changed: true, status: 'running' }); + expect(bridge.controlSessionWorkflowTaskCalls).toEqual([ + { + sessionId: 's-1', + taskId: 'task-1', + action: 'pause', + context: { clientId: 'client-1' }, + }, + { sessionId: 's-1', taskId: 'task-1', action: 'resume' }, + { sessionId: 's-1', taskId: 'task-1', action: 'retry' }, + { sessionId: 's-1', taskId: 'task-1', action: 'rerun' }, + { sessionId: 's-1', taskId: 'task-1', action: 'delete-history' }, + { sessionId: 's-1', taskId: 'deep-review', action: 'run-saved' }, ]); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 03cf5a2ce43..c38b4f743e8 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1928,6 +1928,7 @@ export function createServeApp( maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession, sessionRestoreTimeoutMs, languageCodes, + daemonEnv: daemonEnvAtBoot, }); if (liveVoiceSurfaceAvailable) { diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index 4c8c7a21f40..fc75f74e705 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(59); + expect(registered).toHaveLength(60); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index ea675d074ef..ecb771f4768 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -1062,17 +1062,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 59 unique routes with the audited 57/2 attribution split', () => { + it('contains 60 unique routes with the audited 58/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(59); - expect(new Set(keys).size).toBe(59); + expect(keys).toHaveLength(60); + expect(new Set(keys).size).toBe(60); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(57); + ).toHaveLength(58); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 30b38a60ab7..e13bfa23064 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -181,6 +181,12 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/tasks/:taskId/cancel', }, + { + method: 'POST', + path: '/session/:id/tasks/:taskId/workflow-action', + attribution: 'handler_resolved', + route: 'POST /session/:id/tasks/:taskId/workflow-action', + }, { method: 'POST', path: '/session/:id/goal/clear', diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index 4fb1093d9a3..dd4512ccca2 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -436,6 +436,7 @@ export interface CapabilitiesEnvelope { displayName?: string; primary: boolean; trusted: boolean; + workflowsEnabled?: boolean; removable?: boolean; kind?: 'live'; }>; diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 60c93082c48..48ca84e137b 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -97,7 +97,8 @@ const rootDir = join(__dirname, '..'); // identity-stable transcript block indexes used by browser renderers. // Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel // (`unrecognizedDiagnostics` routing + selector, #8823). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 198 * 1024; +// Bumped from 198KB to 199KB for workflow task status and control APIs. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 199 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a49ea56814b..fd214e2672e 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -71,8 +71,10 @@ import type { DaemonUsageRange, DaemonStatusReport, DaemonStatusReportDetail, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, DaemonUpdateAgentRequest, DaemonWorkspaceFile, DaemonWorkspaceFileBytes, @@ -2975,6 +2977,22 @@ export class DaemonClient { ); } + async sessionWorkflowTasks( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks?includeWorkflows=true`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/tasks'); + } + return (await res.json()) as DaemonSessionWorkflowTasksStatus; + }, + ); + } + async sessionLspStatus( sessionId: string, clientId?: string, @@ -2994,24 +3012,63 @@ export class DaemonClient { async sessionTaskCancel( sessionId: string, taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], clientId?: string, ): Promise<{ cancelled: boolean }> { + return await this.sessionTaskMutation<{ cancelled: boolean }>( + sessionId, + taskId, + 'cancel', + { kind }, + clientId, + ); + } + + async sessionWorkflowTaskAction( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + clientId?: string, + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }> { + return await this.sessionTaskMutation<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }>(sessionId, taskId, 'workflow-action', { action }, clientId); + } + + private async sessionTaskMutation( + sessionId: string, + taskId: string, + route: 'cancel' | 'workflow-action', + body: object, + clientId?: string, + ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/cancel`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/${route}`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), - body: JSON.stringify({ kind }), + body: JSON.stringify(body), }, async (res) => { if (!res.ok) { throw await this.failOnError( res, - 'POST /session/:id/tasks/:taskId/cancel', + `POST /session/:id/tasks/:taskId/${route}`, ); } - return (await res.json()) as { cancelled: boolean }; + return (await res.json()) as T; }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 903e86e9ca0..386150c9308 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -47,8 +47,10 @@ import type { DaemonSession, DaemonSessionStatsStatus, DaemonSessionSupportedCommandsStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, HeartbeatResult, PermissionResponse, PromptContentBlock, @@ -848,13 +850,20 @@ export class DaemonSessionClient { return await this.client.sessionTasks(this.sessionId, this.clientId); } + async workflowTasks(): Promise { + return await this.client.sessionWorkflowTasks( + this.sessionId, + this.clientId, + ); + } + async lspStatus(): Promise { return await this.client.sessionLspStatus(this.sessionId, this.clientId); } async cancelTask( taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], ): Promise<{ cancelled: boolean }> { return await this.client.sessionTaskCancel( this.sessionId, @@ -864,6 +873,22 @@ export class DaemonSessionClient { ); } + async controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }> { + return await this.client.sessionWorkflowTaskAction( + this.sessionId, + taskId, + action, + this.clientId, + ); + } + async clearGoal(): Promise<{ cleared: boolean; condition?: string }> { return await this.client.sessionGoalClear(this.sessionId, this.clientId); } diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index f29cd5e408a..efc522ae204 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -431,7 +431,36 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/tasks$/, mapping: { method: '_qwen/session/tasks', - extractParams: (segs) => ({ sessionId: segs[0] }), + extractParams: (segs, _body, _method, query) => ({ + sessionId: segs[0], + ...boolParam(query, 'includeWorkflows'), + }), + }, + }, + // POST /session/:id/tasks/:taskId/cancel → _qwen/session/tasks/cancel + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/tasks\/([^/]+)\/cancel$/, + mapping: { + method: '_qwen/session/tasks/cancel', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + taskId: segs[1], + }), + }, + }, + // POST /session/:id/tasks/:taskId/workflow-action → _qwen/session/tasks/workflow_action + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/tasks\/([^/]+)\/workflow-action$/, + mapping: { + method: '_qwen/session/tasks/workflow_action', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + taskId: segs[1], + }), }, }, // GET /session/:id/lsp -> _qwen/session/lsp diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index b04f3ba21a3..74d301b1879 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -550,6 +550,12 @@ export type { DaemonSessionLspStatus, DaemonSessionAgentTaskStatus, DaemonSessionMonitorTaskStatus, + DaemonSessionWorkflowTaskStatus, + DaemonWorkflowApprovalStatusEntry, + DaemonWorkflowDispatchStatus, + DaemonWorkflowDispatchStatusEntry, + DaemonWorkflowEvent, + DaemonWorkflowPhaseVisit, DaemonSessionProcessTaskLifecycleStatus, DaemonSessionContextUsage, DaemonSessionContextUsageStatus, @@ -581,7 +587,9 @@ export type { DaemonSessionSupportedCommandsStatus, DaemonSessionTaskLifecycleStatus, DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTasksStatus, DaemonSessionStatsStatus, DaemonSessionStatsModelMetrics, DaemonSessionStatsToolByName, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index fcc54e97616..8f89e1d3f4d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -36,6 +36,8 @@ export interface DaemonWorkspaceCapability { displayName?: string; primary: boolean; trusted: boolean; + /** Whether new sessions in this workspace can use Workflow. */ + workflowsEnabled?: boolean; /** Whether this runtime can be removed without restarting the daemon. */ removable?: boolean; /** Daemon-owned Live conversation runtime. */ @@ -2417,6 +2419,13 @@ export interface DaemonSessionSupportedCommandsStatus { sessionId: string; availableCommands: DaemonAvailableCommand[]; availableSkills: string[]; + /** Whether Workflow is available for this session. */ + workflowsEnabled?: boolean; + /** Reusable workflow definitions visible to this session. */ + savedWorkflows?: Array<{ + name: string; + source: 'project' | 'user'; + }>; } export type DaemonSessionTaskLifecycleStatus = @@ -2505,11 +2514,128 @@ export interface DaemonSessionMonitorTaskStatus { toolUseId?: string; } +export interface DaemonWorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export type DaemonWorkflowDispatchStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface DaemonWorkflowDispatchStatusEntry { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: DaemonWorkflowDispatchStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface DaemonWorkflowApprovalStatusEntry { + approvalId: string; + subagentId: string; + name: string; + description: string; + at: number; +} + +interface DaemonWorkflowEventBase { + id: string; + at: number; +} + +export type DaemonWorkflowEvent = + | (DaemonWorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (DaemonWorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (DaemonWorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (DaemonWorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (DaemonWorkflowEventBase & { type: 'log'; message: string }) + | (DaemonWorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (DaemonWorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (DaemonWorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +export interface DaemonSessionWorkflowTaskStatus { + kind: 'workflow'; + id: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Restored from the project snapshot store; controls are read-only. */ + isHistorical?: boolean; + sourceRunId?: string; + startMode?: 'retry' | 'rerun'; + label: string; + description: string; + status: DaemonSessionTaskLifecycleStatus | 'pausing'; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + isBackgrounded: boolean; + currentPhase: string | null; + phaseVisits: DaemonWorkflowPhaseVisit[]; + dispatches: DaemonWorkflowDispatchStatusEntry[]; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal: number | null; + recentLogs: string[]; + /** Ordered runtime facts; absent for snapshots created before event tracing. */ + events?: DaemonWorkflowEvent[]; + pendingApprovalCount: number; + pendingApprovals?: DaemonWorkflowApprovalStatusEntry[]; + error?: string; +} + export type DaemonSessionTaskStatus = | DaemonSessionAgentTaskStatus | DaemonSessionShellTaskStatus | DaemonSessionMonitorTaskStatus; +export type DaemonSessionTaskWithWorkflowStatus = + | DaemonSessionTaskStatus + | DaemonSessionWorkflowTaskStatus; + export interface DaemonSessionTasksStatus { v: 1; sessionId: string; @@ -2517,6 +2643,13 @@ export interface DaemonSessionTasksStatus { tasks: DaemonSessionTaskStatus[]; } +export interface DaemonSessionWorkflowTasksStatus { + v: 1; + sessionId: string; + now: number; + tasks: DaemonSessionTaskWithWorkflowStatus[]; +} + export interface DaemonLspServerStatus { name: string; status: 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index bbf18334a25..2b1606544b8 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -213,6 +213,12 @@ export { type DaemonSessionLspStatus, type DaemonSessionAgentTaskStatus, type DaemonSessionMonitorTaskStatus, + type DaemonSessionWorkflowTaskStatus, + type DaemonWorkflowApprovalStatusEntry, + type DaemonWorkflowDispatchStatus, + type DaemonWorkflowDispatchStatusEntry, + type DaemonWorkflowEvent, + type DaemonWorkflowPhaseVisit, type DaemonSessionProcessTaskLifecycleStatus, type DaemonSessionDiedData, type DaemonSessionDiedEvent, @@ -242,7 +248,9 @@ export { type DaemonSessionSupportedCommandsStatus, type DaemonSessionTaskLifecycleStatus, type DaemonSessionTaskStatus, + type DaemonSessionTaskWithWorkflowStatus, type DaemonSessionTasksStatus, + type DaemonSessionWorkflowTasksStatus, type DaemonSkillLevel, type DaemonPreflightCell, type DaemonPreflightKind, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index b0a9464552a..a409b45e5fb 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1341,6 +1341,11 @@ describe('DaemonClient', () => { if (req.url.endsWith('/session/with%2Fslash/tasks')) { return jsonResponse(200, tasks); } + if ( + req.url.endsWith('/session/with%2Fslash/tasks?includeWorkflows=true') + ) { + return jsonResponse(200, tasks); + } if (req.url.endsWith('/session/with%2Fslash/lsp')) { return jsonResponse(200, lsp); } @@ -1357,6 +1362,9 @@ describe('DaemonClient', () => { await expect( client.sessionTasks('with/slash', 'client-1'), ).resolves.toEqual(tasks); + await expect( + client.sessionWorkflowTasks('with/slash', 'client-1'), + ).resolves.toEqual(tasks); await expect( client.sessionLspStatus('with/slash', 'client-1'), ).resolves.toEqual(lsp); @@ -1364,6 +1372,10 @@ describe('DaemonClient', () => { ['GET', 'http://daemon/session/with%2Fslash/context'], ['GET', 'http://daemon/session/with%2Fslash/supported-commands'], ['GET', 'http://daemon/session/with%2Fslash/tasks'], + [ + 'GET', + 'http://daemon/session/with%2Fslash/tasks?includeWorkflows=true', + ], ['GET', 'http://daemon/session/with%2Fslash/lsp'], ]); expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ @@ -1371,6 +1383,7 @@ describe('DaemonClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 653243799e9..4093a6632b2 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -1325,7 +1325,10 @@ describe('DaemonSessionClient', () => { availableSkills: ['review'], }); } - if (req.url.endsWith('/session/s-1/tasks')) { + if ( + req.url.endsWith('/session/s-1/tasks') || + req.url.endsWith('/session/s-1/tasks?includeWorkflows=true') + ) { return jsonResponse(200, { v: 1, sessionId: 's-1', @@ -1412,6 +1415,12 @@ describe('DaemonSessionClient', () => { now: 1_700_000_000_000, tasks: [], }); + await expect(session.workflowTasks()).resolves.toEqual({ + v: 1, + sessionId: 's-1', + now: 1_700_000_000_000, + tasks: [], + }); await expect(session.lspStatus()).resolves.toEqual({ v: 1, sessionId: 's-1', @@ -1446,6 +1455,7 @@ describe('DaemonSessionClient', () => { 'http://daemon/session/s-1/context', 'http://daemon/session/s-1/supported-commands', 'http://daemon/session/s-1/tasks', + 'http://daemon/session/s-1/tasks?includeWorkflows=true', 'http://daemon/session/s-1/lsp', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', @@ -1466,6 +1476,7 @@ describe('DaemonSessionClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 5a13fd66204..3438e3d8897 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -460,6 +460,48 @@ describe('acpRouteTable – matchRoute', () => { const result = matchRoute('/session/s17/tasks', 'GET'); expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/tasks'); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + new URLSearchParams('includeWorkflows=true'), + ); + expect(params).toEqual({ sessionId: 's17', includeWorkflows: true }); + }); + + it('POST /session/:id/tasks/:taskId/cancel maps to _qwen/session/tasks/cancel', () => { + const result = matchRoute('/session/s17/tasks/task%2F1/cancel', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/tasks/cancel'); + const params = result!.mapping.extractParams( + result!.segments, + { kind: 'workflow' }, + 'POST', + ); + expect(params).toEqual({ + sessionId: 's17', + taskId: 'task/1', + kind: 'workflow', + }); + }); + + it('POST /session/:id/tasks/:taskId/workflow-action maps to _qwen/session/tasks/workflow_action', () => { + const result = matchRoute( + '/session/s17/tasks/workflow%201/workflow-action', + 'POST', + ); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/tasks/workflow_action'); + const params = result!.mapping.extractParams( + result!.segments, + { action: 'retry' }, + 'POST', + ); + expect(params).toEqual({ + sessionId: 's17', + taskId: 'workflow 1', + action: 'retry', + }); }); it('GET /session/:id/lsp maps to _qwen/session/lsp', () => { diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index d69e5cbbaf3..a8914f7ce34 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -811,6 +811,23 @@ describe('createDaemonSessionActions', () => { ); }); + it('keeps workflow task loading behind an explicit adapter action', async () => { + const session = createMockSession('session-a'); + const { actions } = createActionsHarness({ session }); + + await expect(actions.getTasks()).resolves.toMatchObject({ + sessionId: 'session-a', + tasks: [], + }); + await expect(actions.getWorkflowTasks()).resolves.toMatchObject({ + sessionId: 'session-a', + tasks: [], + }); + + expect(session.tasks).toHaveBeenCalledOnce(); + expect(session.workflowTasks).toHaveBeenCalledOnce(); + }); + it('reports getTasks failures by default', async () => { const session = createMockSession('session-a'); const addNotice = vi.fn((notice) => notice); @@ -969,6 +986,68 @@ describe('createDaemonSessionActions', () => { expect(addNotice).not.toHaveBeenCalled(); }); + it('starts a saved workflow through the session workflow action', async () => { + const session = createMockSession('session-a'); + session.client.sessionWorkflowTaskAction.mockResolvedValueOnce({ + changed: true, + status: 'running', + taskId: 'wf_5678efab', + }); + const { actions } = createActionsHarness({ session }); + + await expect(actions.runSavedWorkflow('deep-review')).resolves.toEqual({ + started: true, + status: 'running', + taskId: 'wf_5678efab', + }); + expect(session.client.sessionWorkflowTaskAction).toHaveBeenCalledWith( + 'session-a', + 'deep-review', + 'run-saved', + 'client-session-a', + ); + }); + + it('suppresses a stale workflow-control failure after switching sessions', async () => { + const sessionA = createMockSession('session-a'); + const sessionB = createMockSession('session-b'); + const pending = createDeferred(); + sessionA.controlWorkflowTask.mockReturnValueOnce(pending.promise); + const addNotice = vi.fn((notice) => notice); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: sessionA, + }); + + const result = actions.controlWorkflowTask('wf-1', 'pause'); + sessionRef.current = sessionB as unknown as DaemonSessionClient; + pending.reject(new Error('old workflow failed')); + + await expect(result).rejects.toThrow('old workflow failed'); + expect(addNotice).not.toHaveBeenCalled(); + }); + + it('suppresses a stale saved-workflow failure after switching sessions', async () => { + const sessionA = createMockSession('session-a'); + const sessionB = createMockSession('session-b'); + const pending = createDeferred(); + sessionA.client.sessionWorkflowTaskAction.mockReturnValueOnce( + pending.promise, + ); + const addNotice = vi.fn((notice) => notice); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: sessionA, + }); + + const result = actions.runSavedWorkflow('deep-review'); + sessionRef.current = sessionB as unknown as DaemonSessionClient; + pending.reject(new Error('old saved workflow failed')); + + await expect(result).rejects.toThrow('old saved workflow failed'); + expect(addNotice).not.toHaveBeenCalled(); + }); + it('aborts active prompts and rejects pending session loads when clearing', async () => { const controller = new AbortController(); const session = createMockSession('session-a'); @@ -1955,6 +2034,7 @@ function createMockSession( })), listWorkspaceSessions: vi.fn(), closeSession: vi.fn(), + sessionWorkflowTaskAction: vi.fn(), removeSessionMedia: vi.fn(async () => true), }, cancel: vi.fn(async () => undefined), @@ -1972,6 +2052,12 @@ function createMockSession( submitPrompt: vi.fn(async () => ({ promptId: 'prompt-1' })), supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)), tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })), + workflowTasks: vi.fn(async () => ({ + v: 1 as const, + sessionId, + tasks: [], + })), + controlWorkflowTask: vi.fn(), }; } diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index baf2f3a7444..e1db8999671 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -20,7 +20,7 @@ import type { DaemonRewindResult, DaemonSessionRecapResult, DaemonRewindSnapshotInfo, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionArtifactsEnvelope, DaemonTranscriptStore, DaemonCapabilities, @@ -1727,7 +1727,43 @@ export function createDaemonSessionActions({ } }, - async cancelTask(taskId: string, kind: DaemonSessionTaskStatus['kind']) { + async getWorkflowTasks(opts) { + const session = sessionRef.current; + if (!session) throw new Error('Daemon session is not connected'); + try { + return await withActionTimeout( + session.workflowTasks(), + 'Get tasks timed out', + ); + } catch (error) { + if ( + error instanceof Error && + error.message === 'Daemon session is not connected' + ) { + throw error; + } + if (opts?.silent && isTransientActionError(error)) { + throw error; + } + throw dispatchActionError( + addNotice, + 'Get tasks failed', + error, + 'load_tasks', + opts?.silent + ? { + dispatchedNoticeKeys: silentHardFailureNoticeKeys, + noticeOnceKey: getActionErrorNoticeKey('load_tasks', error), + } + : undefined, + ); + } + }, + + async cancelTask( + taskId: string, + kind: DaemonSessionTaskWithWorkflowStatus['kind'], + ) { const session = requireSessionForAction( addNotice, sessionRef.current, @@ -1749,6 +1785,59 @@ export function createDaemonSessionActions({ } }, + async controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Control workflow failed', + 'control_workflow', + ); + try { + return await withActionTimeout( + session.controlWorkflowTask(taskId, action), + 'Control workflow timed out', + ); + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Control workflow failed', + error, + 'control_workflow', + ); + } + }, + + async runSavedWorkflow(name: string) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Run saved workflow failed', + 'run_saved_workflow', + ); + try { + const { changed, ...result } = await withActionTimeout( + session.client.sessionWorkflowTaskAction( + session.sessionId, + name, + 'run-saved', + session.clientId, + ), + 'Run saved workflow timed out', + ); + return { started: changed, ...result }; + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Run saved workflow failed', + error, + 'run_saved_workflow', + ); + } + }, + async clearGoal() { const session = requireSessionForAction( addNotice, diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 30f8f85b4a5..bb329d8b913 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -29,8 +29,10 @@ import type { DaemonSession, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, DaemonSessionStatsStatus, DaemonSessionArtifactsEnvelope, DaemonShellCommandResult, @@ -210,6 +212,8 @@ export type DaemonNoticeOperation = | 'load_tasks' | 'load_artifacts' | 'cancel_task' + | 'control_workflow' + | 'run_saved_workflow' | 'clear_goal' | 'load_stats' | 'rewind_snapshots' @@ -491,10 +495,26 @@ export interface DaemonSessionActions { ): Promise; sendShellCommand(command: string): Promise; getTasks(opts?: GetTasksActionOptions): Promise; + getWorkflowTasks( + opts?: GetTasksActionOptions, + ): Promise; cancelTask( taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], ): Promise<{ cancelled: boolean }>; + controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }>; + runSavedWorkflow(name: string): Promise<{ + started: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }>; clearGoal(): Promise<{ cleared: boolean; condition?: string }>; getStats(): Promise; loadArtifacts(): Promise; From bea074620f14c2bae6294bb0a22fb79e980100c7 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:54:01 +0800 Subject: [PATCH 02/23] fix(serve): align workflow capability gating --- packages/cli/src/serve/routes/capabilities.ts | 5 ++++- packages/cli/src/serve/run-qwen-serve.ts | 7 +++++++ packages/cli/src/serve/server.test.ts | 19 +++++++++++++++++++ packages/cli/src/serve/workspace-registry.ts | 1 + 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/routes/capabilities.ts b/packages/cli/src/serve/routes/capabilities.ts index 96de88fbe6d..5f37e3d179f 100644 --- a/packages/cli/src/serve/routes/capabilities.ts +++ b/packages/cli/src/serve/routes/capabilities.ts @@ -48,7 +48,10 @@ function workflowsEnabledForRuntime( ? (runtime.env.effectiveEnv ?? {}) : (runtime.env.effectiveEnv ?? daemonEnv); if (env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false; - return env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1'; + return ( + env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1' || + runtime.env.workflowsEnabledBySettings === true + ); } export function registerCapabilitiesRoutes( diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index d72c1bd88a1..a2493c48c4f 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -3722,6 +3722,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -3729,6 +3730,8 @@ async function runQwenServeImpl( mode: 'runtime-overlay' as const, overlayKeys: [...runtimeEnvSnapshot.overlayKeys], effectiveEnv: runtimeEffectiveEnv, + workflowsEnabledBySettings: + runtimeBootSettings?.merged.tools?.workflowsEnabled === true, envFilePaths: [...runtimeEnvSnapshot.envFilePaths], envFileReadFailed: runtimeEnvSnapshot.envFileReadFailed, envFileReadFailures: [...runtimeEnvSnapshot.envFileReadFailures], @@ -4552,6 +4555,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -4589,6 +4593,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -4596,6 +4601,8 @@ async function runQwenServeImpl( mode: 'runtime-overlay', overlayKeys: [...snapshot.overlayKeys], effectiveEnv, + workflowsEnabledBySettings: + settings?.merged.tools?.workflowsEnabled === true, envFilePaths: [...snapshot.envFilePaths], envFileReadFailed: snapshot.envFileReadFailed, envFileReadFailures: [...snapshot.envFileReadFailures], diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index cce0882aa08..35d0dd849a7 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -3987,6 +3987,20 @@ describe('createServeApp', () => { }, }, }; + const settingsEnabled = { + ...makeWorkspaceRuntimeForTest({ + workspaceId: 'settings-enabled-id', + workspaceCwd: '/workspace/settings-enabled', + primary: false, + bridge: fakeBridge(), + }), + env: { + mode: 'runtime-overlay' as const, + overlayKeys: [], + effectiveEnv: {}, + workflowsEnabledBySettings: true, + }, + }; const untrusted = makeWorkspaceRuntimeForTest({ workspaceId: 'untrusted-id', workspaceCwd: '/workspace/untrusted', @@ -3999,6 +4013,7 @@ describe('createServeApp', () => { workspaceRegistry: createWorkspaceRegistry([ primary, secondary, + settingsEnabled, untrusted, ]), daemonEnv: { QWEN_CODE_ENABLE_WORKFLOWS: '1' }, @@ -4018,6 +4033,10 @@ describe('createServeApp', () => { id: 'secondary-id', workflowsEnabled: false, }), + expect.objectContaining({ + id: 'settings-enabled-id', + workflowsEnabled: true, + }), expect.objectContaining({ id: 'untrusted-id', workflowsEnabled: false, diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index e65248fb96b..c1038da59cf 100644 --- a/packages/cli/src/serve/workspace-registry.ts +++ b/packages/cli/src/serve/workspace-registry.ts @@ -18,6 +18,7 @@ export interface WorkspaceRuntimeEnvMetadata { readonly mode: 'parent-process' | 'runtime-overlay'; readonly overlayKeys: readonly string[]; readonly effectiveEnv?: Readonly; + readonly workflowsEnabledBySettings?: boolean; readonly envFilePaths?: readonly string[]; readonly envFileReadFailed?: boolean; readonly envFileReadFailures?: ReadonlyArray<{ From 6febdb24f59a0b95200f58ad442c61f0bd488316 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:00:17 +0800 Subject: [PATCH 03/23] fix(cli): reject live workflow history deletion --- .../src/acp-integration/session/Session.test.ts | 14 ++++++++++++++ .../cli/src/acp-integration/session/Session.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 7859eb77556..43671e490a2 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -493,6 +493,7 @@ describe('Session', () => { clearStatusChangeCallback: ReturnType; setApprovalRequestCallback: ReturnType; resolvePendingApproval: ReturnType; + get: ReturnType; getHandle: ReturnType; }; let mockGoalRuntime: { @@ -712,6 +713,7 @@ describe('Session', () => { clearStatusChangeCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), resolvePendingApproval: vi.fn().mockResolvedValue(true), + get: vi.fn().mockReturnValue(undefined), getHandle: vi.fn().mockReturnValue(undefined), }; @@ -2382,6 +2384,18 @@ describe('Session', () => { ); }); + it('rejects history deletion while the workflow is active', async () => { + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'paused' }); + + await expect(session.deleteWorkflowHistory('wf_active')).resolves.toBe( + false, + ); + + expect(mockWorkflowRunRegistry.getHandle).not.toHaveBeenCalled(); + expect(listWorkflowSnapshotsSpy).not.toHaveBeenCalled(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + it('does not infer Todo ownership from Todo Stop Guard lineage', async () => { mockChat.sendMessageStream = vi .fn() diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d87f1972c53..27817087981 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3158,7 +3158,10 @@ export class Session implements SessionContext { } async deleteWorkflowHistory(runId: string): Promise { - const handle = this.config.getWorkflowRunRegistry().getHandle(runId); + const registry = this.config.getWorkflowRunRegistry(); + const entry = registry.get(runId); + if (entry && !isTerminalWorkflowStatus(entry.status)) return false; + const handle = registry.getHandle(runId); if (handle) await handle.completion; await this.refreshWorkflowHistory(); if (!this.workflowHistory.some((item) => item.runId === runId)) { From fdadec7f796b4e1b29ff2570004da86a9d5d7ec0 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:32:49 +0800 Subject: [PATCH 04/23] fix(serve): start controlled workflows in background --- .../cli/src/acp-integration/acpAgent.test.ts | 21 +++++------- packages/cli/src/acp-integration/acpAgent.ts | 33 ++++++++++++++----- .../core/src/tools/workflow/workflow.test.ts | 31 +++++++++++++++++ packages/core/src/tools/workflow/workflow.ts | 15 +++++++++ 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index a4c924d8397..0985b15f810 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -10223,13 +10223,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }); return { llmContent: 'started', workflowRunId: 'wf_5678efab' }; }); - const build = vi.fn().mockReturnValue({ execute }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); Object.assign(innerConfig, { isWorkflowsEnabled: vi.fn().mockReturnValue(true), getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn((name: string) => - name === 'workflow' ? { build } : undefined, + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, ), }), }); @@ -10265,9 +10265,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { status: 'running', taskId: 'wf_5678efab', }); - expect(build).toHaveBeenCalledWith({ + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ scriptPath: '/tmp/.qwen/workflows/deep-review.js', - run_in_background: true, }); expect(execute).toHaveBeenCalledOnce(); @@ -10295,12 +10294,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { task.status = 'running'; return { llmContent: 'started' }; }); - const build = vi.fn().mockReturnValue({ execute }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); Object.assign(innerConfig, { getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn((name: string) => - name === 'workflow' ? { build } : undefined, + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, ), }), }); @@ -10326,11 +10325,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { action: 'retry', }), ).resolves.toEqual({ changed: true, status: 'running' }); - expect(build).toHaveBeenCalledWith({ + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ script: task.script, args: task.args, resumeFromRunId: task.runId, - run_in_background: true, }); expect(execute).toHaveBeenCalledOnce(); @@ -10382,12 +10380,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { tasks.push(concurrentRerun, rerun); return { llmContent: 'started', workflowRunId: rerun.runId }; }); - const build = vi.fn().mockReturnValue({ execute }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); Object.assign(innerConfig, { getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn((name: string) => - name === 'workflow' ? { build } : undefined, + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, ), }), }); @@ -10417,10 +10415,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { status: 'running', taskId: rerun.runId, }); - expect(build).toHaveBeenCalledWith({ + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ script: task.script, args: task.args, - run_in_background: true, }); expect(execute).toHaveBeenCalledOnce(); expect(rerun).toMatchObject({ diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 3546820c6c6..6ae75d36811 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -416,6 +416,25 @@ import { type GenerationEvent, } from './generation.js'; +type SessionOwnedWorkflowTool = { + buildSessionOwnedBackground( + params: Omit, + ): { + execute(signal: AbortSignal): Promise; + }; +}; + +function isSessionOwnedWorkflowTool( + value: unknown, +): value is SessionOwnedWorkflowTool { + return ( + typeof value === 'object' && + value !== null && + 'buildSessionOwnedBackground' in value && + typeof value.buildSessionOwnedBackground === 'function' + ); +} + const debugLogger = createDebugLogger('ACP_AGENT'); const QWEN_ACP_LOCAL_READ_ROOTS_ENV = 'QWEN_ACP_LOCAL_READ_ROOTS'; const POSIX_TMP_LOCAL_READ_ROOT = '/tmp'; @@ -11331,17 +11350,16 @@ class QwenAgent implements Agent { const workflowTool = config .getToolRegistry() .getTool(ToolNames.WORKFLOW); - if (!workflowTool) { + if (!isSessionOwnedWorkflowTool(workflowTool)) { throw RequestError.invalidParams( undefined, 'The workflow tool is unavailable; cannot run this saved workflow.', ); } const result = (await workflowTool - .build({ + .buildSessionOwnedBackground({ scriptPath: savedWorkflow.scriptPath, - run_in_background: true, - } satisfies WorkflowParams) + }) .execute(new AbortController().signal)) as WorkflowToolResult; const startedTask = result.workflowRunId ? registry.get(result.workflowRunId) @@ -11369,20 +11387,19 @@ class QwenAgent implements Agent { const workflowTool = config .getToolRegistry() .getTool(ToolNames.WORKFLOW); - if (!workflowTool) { + if (!isSessionOwnedWorkflowTool(workflowTool)) { throw RequestError.invalidParams( undefined, `The workflow tool is unavailable; cannot ${action} this run.`, ); } - const startParams: WorkflowParams = { + const startParams: Omit = { script: task.script, args: task.args, ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), - run_in_background: true, }; const result = (await workflowTool - .build(startParams) + .buildSessionOwnedBackground(startParams) .execute(new AbortController().signal)) as WorkflowToolResult; if (action === 'rerun') { const rerunTask = result.workflowRunId diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index a2c80389b2a..83f3db2a760 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -220,6 +220,11 @@ describe('WorkflowTool', () => { run_in_background: true, }), ).toThrow(/completion channel/i); + expect(() => + new WorkflowTool(interactiveConfig).buildSessionOwnedBackground({ + script: 'return 1', + }), + ).toThrow(/completion channel/i); interactiveRegistry.setCompletionCallback(vi.fn()); const acpConfig = { @@ -235,6 +240,32 @@ describe('WorkflowTool', () => { ).toThrow(/interactive TUI/i); }); + it('starts a session-owned background run outside the interactive TUI', async () => { + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { + storage: new Storage(path.join(os.tmpdir(), 'workflow-session-owned')), + isInteractive: () => false, + getWorkflowRunRegistry: () => registry, + getSkipWorkflowUsageWarning: () => true, + } as unknown as Config; + + const result = await new WorkflowTool(config, { + dispatch: async () => 'unused', + }) + .buildSessionOwnedBackground({ + script: `phase('Inspect'); return { status: 'ready' };`, + }) + .execute(new AbortController().signal); + + expect(result.workflowRunId).toMatch(/^wf_[0-9a-f]+$/); + const run = registry.get(result.workflowRunId!); + expect(run?.isBackgrounded).toBe(true); + await vi.waitFor(() => + expect(registry.get(result.workflowRunId!)?.status).toBe('completed'), + ); + }); + it('does not register a background run when the caller is already aborted', async () => { const registry = new WorkflowRunRegistry(); registry.setCompletionCallback(vi.fn()); diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index 0853db8b61f..8635577dc1c 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -633,6 +633,21 @@ export class WorkflowTool extends BaseDeclarativeTool< ); } + buildSessionOwnedBackground( + params: Omit, + ): ToolInvocation { + const validationError = this.validateToolParams(params); + if (validationError) { + throw new Error(validationError); + } + if (!this.config.getWorkflowRunRegistry().hasCompletionCallback()) { + throw new Error( + 'WorkflowTool: session-owned background runs require an active workflow completion channel.', + ); + } + return this.createInvocation({ ...params, run_in_background: true }); + } + protected override validateToolParamValues( params: WorkflowParams, ): string | null { From 135bf453728cb9ebef1c0ff54093845f78cf1f05 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:29:52 +0800 Subject: [PATCH 05/23] fix(serve): address workflow control review blockers --- packages/acp-bridge/src/bridgeTypes.ts | 11 +- .../cli/src/acp-integration/acpAgent.test.ts | 96 ++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 76 +++++---- .../acp-integration/session/Session.test.ts | 144 +++++++++++++++++- .../src/acp-integration/session/Session.ts | 54 +++++-- packages/cli/src/serve/run-qwen-serve.test.ts | 31 ++++ packages/cli/src/serve/run-qwen-serve.ts | 6 + 7 files changed, 372 insertions(+), 46 deletions(-) diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 5846ef71be0..b7d360e0c2b 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -291,11 +291,15 @@ export const ACTIVE_WORK_MAX_SESSION_HOLDS = 1024; export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery'; /** - * Work categories a child reports holds for. Monitors, workflows, and cron - * remain outside `activeWork`'s declared scope. The category travels on every + * Work categories a child reports holds for. Monitors and cron remain outside + * `activeWork`'s declared scope. The category travels on every * hold so peers can negotiate coverage explicitly when the scope widens. */ -export type ActiveWorkHoldCategory = 'agent' | 'notification' | 'shell'; +export type ActiveWorkHoldCategory = + | 'agent' + | 'notification' + | 'shell' + | 'workflow'; /** Categories understood by active-work v1 before category negotiation was * added to the daemon's initialize request. */ @@ -306,6 +310,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [ 'agent', 'notification', 'shell', + 'workflow', ]; export interface ActiveWorkHeartbeatCapabilityV1 { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 0985b15f810..c659f42c105 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -10336,6 +10336,102 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it.each(['retry', 'rerun'] as const)( + 'admits only one overlapping %s request for a workflow task', + async (action) => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const rerunTask = { + ...task, + id: 'wf_5678efab', + runId: 'wf_5678efab', + status: 'running' as const, + }; + let releaseStart!: () => void; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + let rerunStarted = false; + const registry = { + get: vi.fn((runId: string) => { + if (runId === task.runId) return task; + if (rerunStarted && runId === rerunTask.runId) return rerunTask; + return undefined; + }), + getHandle: vi.fn(() => undefined), + setLineage: vi.fn().mockReturnValue(true), + }; + const execute = vi.fn().mockImplementation(async () => { + await startGate; + if (action === 'retry') task.status = 'running'; + else rerunStarted = true; + return { + llmContent: 'started', + ...(action === 'rerun' ? { workflowRunId: rerunTask.runId } : {}), + }; + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const first = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId, taskId: task.runId, action }, + ); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action, + }), + ).resolves.toEqual({ changed: false, status: 'failed' }); + expect(execute).toHaveBeenCalledOnce(); + + releaseStart(); + await expect(first).resolves.toEqual( + action === 'retry' + ? { changed: true, status: 'running' } + : { + changed: true, + status: 'running', + taskId: rerunTask.runId, + }, + ); + + mockConnectionState.resolve(); + await agentPromise; + }, + ); + it('reruns a terminal workflow from scratch with a new run id', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 6ae75d36811..8dbc9e16065 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3967,6 +3967,7 @@ class QwenAgent implements Agent { private sessions: Map = new Map(); private readonly historyMutationTails = new Map>(); private readonly startingSessionIds = new Set(); + private readonly startingWorkflowTaskIds = new Set(); private activePromptCalls = new Map>(); private workspaceMcpDiscoveryConfig: Config | undefined; private workspaceMcpDiscoveryPromise: Promise | undefined; @@ -11384,42 +11385,51 @@ class QwenAgent implements Agent { if (!canStart || !task.script) { return { changed: false, status: task.status }; } - const workflowTool = config - .getToolRegistry() - .getTool(ToolNames.WORKFLOW); - if (!isSessionOwnedWorkflowTool(workflowTool)) { - throw RequestError.invalidParams( - undefined, - `The workflow tool is unavailable; cannot ${action} this run.`, - ); + const startClaim = `${sessionId}\0${taskId}`; + if (this.startingWorkflowTaskIds.has(startClaim)) { + return { changed: false, status: task.status }; } - const startParams: Omit = { - script: task.script, - args: task.args, - ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), - }; - const result = (await workflowTool - .buildSessionOwnedBackground(startParams) - .execute(new AbortController().signal)) as WorkflowToolResult; - if (action === 'rerun') { - const rerunTask = result.workflowRunId - ? registry.get(result.workflowRunId) - : undefined; - if (rerunTask) { - registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + this.startingWorkflowTaskIds.add(startClaim); + try { + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!isSessionOwnedWorkflowTool(workflowTool)) { + throw RequestError.invalidParams( + undefined, + `The workflow tool is unavailable; cannot ${action} this run.`, + ); } - return rerunTask - ? { - changed: true, - status: rerunTask.status, - taskId: rerunTask.runId, - } - : { changed: false, status: task.status }; + const startParams: Omit = { + script: task.script, + args: task.args, + ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), + }; + const result = (await workflowTool + .buildSessionOwnedBackground(startParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + if (action === 'rerun') { + const rerunTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + if (rerunTask) { + registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + } + return rerunTask + ? { + changed: true, + status: rerunTask.status, + taskId: rerunTask.runId, + } + : { changed: false, status: task.status }; + } + return { + changed: true, + status: registry.get(taskId)?.status, + }; + } finally { + this.startingWorkflowTaskIds.delete(startClaim); } - return { - changed: true, - status: registry.get(taskId)?.status, - }; } const changed = action === 'pause' ? registry.pause(taskId) : registry.resume(taskId); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 43671e490a2..21ca0b2112c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -495,6 +495,7 @@ describe('Session', () => { resolvePendingApproval: ReturnType; get: ReturnType; getHandle: ReturnType; + list: ReturnType; }; let mockGoalRuntime: { getSnapshot: ReturnType; @@ -715,6 +716,7 @@ describe('Session', () => { resolvePendingApproval: vi.fn().mockResolvedValue(true), get: vi.fn().mockReturnValue(undefined), getHandle: vi.fn().mockReturnValue(undefined), + list: vi.fn().mockReturnValue([]), }; mockChatRecordingService = { @@ -1020,7 +1022,9 @@ describe('Session', () => { ); } - function holdIds(category: 'agent' | 'notification' | 'shell'): string[] { + function holdIds( + category: 'agent' | 'notification' | 'shell' | 'workflow', + ): string[] { return session .collectActiveWorkHolds() .filter((hold) => hold.category === category) @@ -1096,6 +1100,23 @@ describe('Session', () => { session.dispose(); }); + it('holds each non-terminal workflow run', () => { + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'wf-running', status: 'running' }, + { runId: 'wf-paused', status: 'paused' }, + { runId: 'wf-complete', status: 'completed' }, + ]); + createReportingSession(); + + expect(holdIds('workflow')).toEqual(['wf-running', 'wf-paused']); + expect(session.isIdle()).toBe(false); + + mockWorkflowRunRegistry.list.mockReturnValue([]); + expect(session.collectActiveWorkHolds()).toEqual([]); + expect(session.isIdle()).toBe(true); + session.dispose(); + }); + it('tracks shell status changes and retracts only its callback', () => { createReportingSession(); const statusChanged = @@ -1180,6 +1201,37 @@ describe('Session', () => { session.dispose(); }); + it('holds a queued workflow completion notification', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + createReportingSession(); + const releaseCloseGate = session.beginClose(); + const notify = + mockWorkflowRunRegistry.setCompletionCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { runId: string; status: 'completed' }, + ) => void; + + notify('Workflow completed.', '', { + runId: 'wf-queued', + status: 'completed', + }); + + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + expect(holdIds('notification')).toEqual(['wf-queued']); + expect(session.isIdle()).toBe(false); + + releaseCloseGate(); + await vi.waitFor(() => + expect(session.collectActiveWorkHolds()).toEqual([]), + ); + session.dispose(); + }); + it('does not hold for a Monitor notification', async () => { let finishPersistence!: () => void; mockChatRecordingService.recordNotificationStrict.mockImplementationOnce( @@ -2285,6 +2337,47 @@ describe('Session', () => { ]); }); + it('keeps cached terminal workflow history when its disk write is missing', async () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf-unpersisted', + kind: 'workflow', + runId: 'wf-unpersisted', + description: 'Unpersisted run', + meta: null, + status: 'completed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await session.refreshWorkflowHistory(); + + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf-unpersisted' }), + ]); + }); + it('keeps cached history on disk failure and removes it after deletion', async () => { session.dispose(); const onActiveWorkChanged = vi.fn(); @@ -33673,6 +33766,55 @@ describe('Session', () => { internals.notificationProcessing = false; }); + it('classifies workflow notifications from the captured baseline', () => { + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'baseline-workflow', status: 'running' }, + ]); + rebuildSessionWithGuard(); + const internals = session as unknown as { + notificationProcessing: boolean; + notificationQueue: Array<{ + taskId: string; + continuesTodoStopGuardWorkChain: boolean; + }>; + }; + internals.notificationProcessing = true; + const callback = + mockWorkflowRunRegistry.setCompletionCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { + runId: string; + status: 'completed'; + todoWorkChainId?: string; + }, + ) => void; + + callback('baseline result', '', { + runId: 'baseline-workflow', + status: 'completed', + todoWorkChainId: 'stale-chain', + }); + callback('new result', '', { + runId: 'new-workflow', + status: 'completed', + }); + + expect(internals.notificationQueue).toEqual([ + expect.objectContaining({ + taskId: 'baseline-workflow', + continuesTodoStopGuardWorkChain: false, + }), + expect.objectContaining({ + taskId: 'new-workflow', + continuesTodoStopGuardWorkChain: true, + }), + ]); + internals.notificationProcessing = false; + }); + it('commits and rolls back existing-agent lineage around send_message execution', async () => { mockBackgroundTaskRegistry.getAll.mockReturnValue([ { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 27817087981..a4f3344dc37 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -522,6 +522,7 @@ type TodoStopGuardBackgroundBaseline = { agents: Set; shells: Set; monitors: Set; + workflows: Set; wakeups: Set; }; @@ -1850,13 +1851,14 @@ export class Session implements SessionContext { private notificationAbortController: AbortController | null = null; private notificationCompletion: Promise | null = null; private currentAgentNotificationTaskId: string | null = null; + private currentWorkflowNotificationTaskId: string | null = null; private currentShellNotificationActive = false; private readonly persistedBackgroundNotificationTaskIds = new Set(); private readonly backgroundNotificationAcceptances = new Map< string, Promise >(); - private readonly activeAgentNotificationAcceptances = new Set(); + private readonly activeNotificationAcceptances = new Set(); private readonly goalQueue: AcpGoalTurn[] = []; private goalProcessing = false; @@ -2772,6 +2774,7 @@ export class Session implements SessionContext { const agents = this.config.getBackgroundTaskRegistry?.()?.getAll?.() ?? []; const shells = this.config.getBackgroundShellRegistry?.()?.getAll?.() ?? []; const monitors = this.config.getMonitorRegistry?.()?.getAll?.() ?? []; + const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? []; const wakeups = this.config.isCronEnabled?.() ? (this.config.getCronScheduler?.()?.list?.() ?? []).filter( (job) => job.cronExpr === '@wakeup', @@ -2797,6 +2800,7 @@ export class Session implements SessionContext { .filter((item) => item.kind === 'monitor') .map((item) => item.taskId), ]), + workflows: new Set(workflows.map((task) => task.runId)), wakeups: new Set([ ...wakeups.map((job) => job.id), ...this.cronQueue.flatMap((item) => @@ -2899,6 +2903,17 @@ export class Session implements SessionContext { return true; } + const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? []; + if ( + workflows.some( + (task) => + !baseline.workflows.has(task.runId) && + !isTerminalWorkflowStatus(task.status), + ) + ) { + return true; + } + if (!this.config.isCronEnabled?.()) return false; const wakeups = this.config.getCronScheduler?.()?.list?.() ?? []; return wakeups.some( @@ -3153,7 +3168,14 @@ export class Session implements SessionContext { } async refreshWorkflowHistory(): Promise { - this.workflowHistory = await listWorkflowSnapshots(this.config); + const persisted = await listWorkflowSnapshots(this.config); + const byRunId = new Map( + this.workflowHistory.map((snapshot) => [snapshot.runId, snapshot]), + ); + for (const snapshot of persisted) byRunId.set(snapshot.runId, snapshot); + this.workflowHistory = [...byRunId.values()] + .sort((a, b) => b.startTime - a.startTime) + .slice(0, MAX_RETAINED_SNAPSHOTS); return this.workflowHistory; } @@ -3265,14 +3287,19 @@ export class Session implements SessionContext { } const notificationIds = new Set(); for (const item of this.notificationQueue) { - if (item.kind === 'agent') notificationIds.add(item.taskId); + if (item.kind === 'agent' || item.kind === 'workflow') { + notificationIds.add(item.taskId); + } } - for (const taskId of this.activeAgentNotificationAcceptances) { + for (const taskId of this.activeNotificationAcceptances) { notificationIds.add(taskId); } if (this.currentAgentNotificationTaskId !== null) { notificationIds.add(this.currentAgentNotificationTaskId); } + if (this.currentWorkflowNotificationTaskId !== null) { + notificationIds.add(this.currentWorkflowNotificationTaskId); + } for (const taskId of notificationIds) { holds.push({ category: 'notification', id: taskId }); } @@ -3283,6 +3310,11 @@ export class Session implements SessionContext { if (shellActive) { holds.push({ category: 'shell', id: 'background-shells' }); } + for (const task of this.config.getWorkflowRunRegistry().list()) { + if (!isTerminalWorkflowStatus(task.status)) { + holds.push({ category: 'workflow', id: task.runId }); + } + } return holds; } @@ -8227,7 +8259,8 @@ export class Session implements SessionContext { taskId: meta.runId, status: meta.status, kind: 'workflow', - continuesTodoStopGuardWorkChain: meta.todoWorkChainId !== undefined, + continuesTodoStopGuardWorkChain: + !this.todoStopGuardBackgroundBaseline.workflows.has(meta.runId), todoWorkChainId: meta.todoWorkChainId, }); }); @@ -8319,8 +8352,8 @@ export class Session implements SessionContext { const acceptance = this.#persistDaemonBackgroundNotification(item); this.backgroundNotificationAcceptances.set(item.taskId, acceptance); - if (item.kind === 'agent') { - this.activeAgentNotificationAcceptances.add(item.taskId); + if (item.kind === 'agent' || item.kind === 'workflow') { + this.activeNotificationAcceptances.add(item.taskId); this.#activeWorkChanged(); } try { @@ -8330,8 +8363,8 @@ export class Session implements SessionContext { this.backgroundNotificationAcceptances.get(item.taskId) === acceptance ) { this.backgroundNotificationAcceptances.delete(item.taskId); - if (item.kind === 'agent') { - this.activeAgentNotificationAcceptances.delete(item.taskId); + if (item.kind === 'agent' || item.kind === 'workflow') { + this.activeNotificationAcceptances.delete(item.taskId); this.#activeWorkChanged(); } } @@ -8453,6 +8486,8 @@ export class Session implements SessionContext { if (!item) break; this.currentAgentNotificationTaskId = item.kind === 'agent' ? item.taskId : null; + this.currentWorkflowNotificationTaskId = + item.kind === 'workflow' ? item.taskId : null; this.currentShellNotificationActive = item.kind === 'shell'; this.#activeWorkChanged(); try { @@ -8463,6 +8498,7 @@ export class Session implements SessionContext { ); } finally { this.currentAgentNotificationTaskId = null; + this.currentWorkflowNotificationTaskId = null; this.currentShellNotificationActive = false; this.#activeWorkChanged(); } diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 2210a23a0ee..33d2ccf22cb 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -74,6 +74,7 @@ import { import { getDeferredRuntimeRequestTiming } from './server/request-helpers.js'; import type { WorkspaceFileSystemFactory } from './fs/workspace-file-system.js'; import { ConversationWorkspace } from './conversations/conversation-workspace.js'; +import type { WorkspaceRuntimeProvenance } from './managed-scratch-workspace.js'; const originalTestRuntimeDir = process.env['QWEN_RUNTIME_DIR']; const isolatedTestRuntimeDir = fs.realpathSync( @@ -4610,6 +4611,7 @@ describe('runQwenServe runtime startup failures', () => { () => ({ merged: { + tools: { workflowsEnabled: !runtimeMounted }, advanced: { runtimeOutputDir: runtimeMounted ? '.runtime-reloaded' @@ -4688,6 +4690,7 @@ describe('runQwenServe runtime startup failures', () => { const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot'); expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir); expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(primaryRuntime?.env.workflowsEnabledBySettings).toBe(true); await workspace!.reload({ route: 'POST /workspace/reload', @@ -4704,6 +4707,7 @@ describe('runQwenServe runtime startup failures', () => { expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined(); expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir); expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(primaryRuntime?.env.workflowsEnabledBySettings).toBe(false); } finally { if (originalBase === undefined) { delete process.env['QWEN_TEST_BOOT_BASE']; @@ -4858,21 +4862,28 @@ describe('runQwenServe runtime startup failures', () => { ); const primary = path.join(tmpDir, 'primary'); const secondary = path.join(tmpDir, 'secondary'); + const dynamic = path.join(tmpDir, 'dynamic'); const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; delete process.env['QWEN_RUNTIME_DIR']; fs.mkdirSync(primary); fs.mkdirSync(secondary); + fs.mkdirSync(dynamic); vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ enabled: false, sensitiveSpanAttributeMaxLength: 1024 * 1024, }); let runtimeMounted = false; + let dynamicReloaded = false; vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation( (...args: Parameters) => { const workspace = args[0]; const isSecondary = workspace === secondary; return { merged: { + tools: { + workflowsEnabled: + workspace === dynamic ? !dynamicReloaded : !runtimeMounted, + }, advanced: { runtimeOutputDir: isSecondary ? runtimeMounted @@ -4912,10 +4923,17 @@ describe('runQwenServe runtime startup failures', () => { let workspaceRegistry: | import('./workspace-registry.js').WorkspaceRegistry | undefined; + let createWorkspaceRuntime: + | (( + cwd: string, + options: { provenance: WorkspaceRuntimeProvenance }, + ) => Promise) + | undefined; vi.spyOn(serverModule, 'createServeApp').mockImplementation( (_opts, _getPort, deps) => { runtimeMounted = true; workspaceRegistry = deps?.workspaceRegistry; + createWorkspaceRuntime = deps?.createWorkspaceRuntime; return express(); }, ); @@ -4951,6 +4969,7 @@ describe('runQwenServe runtime startup failures', () => { pinnedRuntimeBaseDir, ); expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(env.workflowsEnabledBySettings).toBe(true); await secondaryRuntime!.workspaceService.reload({ route: 'POST /workspace/reload', @@ -4965,6 +4984,18 @@ describe('runQwenServe runtime startup failures', () => { pinnedRuntimeBaseDir, ); expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(env.workflowsEnabledBySettings).toBe(false); + + const dynamicRuntime = await createWorkspaceRuntime!(dynamic, { + provenance: 'existing', + }); + expect(dynamicRuntime.env.workflowsEnabledBySettings).toBe(true); + dynamicReloaded = true; + await dynamicRuntime.workspaceService.reload({ + route: 'POST /workspace/reload', + workspaceCwd: dynamic, + }); + expect(dynamicRuntime.env.workflowsEnabledBySettings).toBe(false); } finally { await handle.close(); if (originalRuntimeDir === undefined) { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index a2493c48c4f..0c1ab46f23a 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -4450,6 +4450,8 @@ async function runQwenServeImpl( workspace, trustedWorkspace, ); + primaryRuntimeEnv.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; let refreshedRuntimeEnv: ReturnType< EnvironmentRuntime['buildRuntimeEnvironment'] >; @@ -4882,6 +4884,8 @@ async function runQwenServeImpl( workspace, secondaryTrusted, ); + secondaryEnv.metadata.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; try { const refreshedRuntimeEnv = settingsRuntime.environment.buildRuntimeEnvironment( @@ -5467,6 +5471,8 @@ async function runQwenServeImpl( workspace, trusted, ); + wsEnv.metadata.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; // Mirror the startup secondary-workspace path: rebuild the runtime // env snapshot and update the metadata so `.env` changes actually // propagate to child processes spawned by this workspace's bridge. From 4ab652acf292aec65142dca1d8126e917ede46b1 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:24:16 +0800 Subject: [PATCH 06/23] fix(serve): make workflow history deletion race-safe --- .../cli/src/acp-integration/acpAgent.test.ts | 71 ++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 20 +++-- .../acp-integration/session/Session.test.ts | 85 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 47 ++++++++-- .../src/agents/workflow-run-registry.test.ts | 26 ++++++ .../core/src/agents/workflow-run-registry.ts | 17 +++- 6 files changed, 254 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c659f42c105..9a8af106432 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -10432,6 +10432,77 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ); + it('does not start a retry while deleting the same workflow history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as const, + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockResolvedValue({ llmContent: 'started' }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + let finishDeletion!: () => void; + const deletionGate = new Promise((resolve) => { + finishDeletion = resolve; + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.deleteWorkflowHistory.mockImplementationOnce(async () => { + await deletionGate; + return true; + }); + const deletion = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId, taskId: task.runId, action: 'delete-history' }, + ); + await vi.waitFor(() => + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledOnce(), + ); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action: 'retry', + }), + ).resolves.toEqual({ changed: false, status: 'failed' }); + expect(execute).not.toHaveBeenCalled(); + + finishDeletion(); + await expect(deletion).resolves.toEqual({ changed: true }); + mockConnectionState.resolve(); + await agentPromise; + }); + it('reruns a terminal workflow from scratch with a new run id', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 8dbc9e16065..6a60b9955ea 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3967,7 +3967,7 @@ class QwenAgent implements Agent { private sessions: Map = new Map(); private readonly historyMutationTails = new Map>(); private readonly startingSessionIds = new Set(); - private readonly startingWorkflowTaskIds = new Set(); + private readonly mutatingWorkflowTaskIds = new Set(); private activePromptCalls = new Map>(); private workspaceMcpDiscoveryConfig: Config | undefined; private workspaceMcpDiscoveryPromise: Promise | undefined; @@ -11339,8 +11339,17 @@ class QwenAgent implements Agent { if (!this.canUseWorkflowControls(config)) { return { changed: false }; } + const mutationClaim = `${sessionId}\0${taskId}`; if (action === 'delete-history') { - return { changed: await session.deleteWorkflowHistory(taskId) }; + if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { + return { changed: false }; + } + this.mutatingWorkflowTaskIds.add(mutationClaim); + try { + return { changed: await session.deleteWorkflowHistory(taskId) }; + } finally { + this.mutatingWorkflowTaskIds.delete(mutationClaim); + } } const registry = config.getWorkflowRunRegistry(); if (action === 'run-saved') { @@ -11385,11 +11394,10 @@ class QwenAgent implements Agent { if (!canStart || !task.script) { return { changed: false, status: task.status }; } - const startClaim = `${sessionId}\0${taskId}`; - if (this.startingWorkflowTaskIds.has(startClaim)) { + if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { return { changed: false, status: task.status }; } - this.startingWorkflowTaskIds.add(startClaim); + this.mutatingWorkflowTaskIds.add(mutationClaim); try { const workflowTool = config .getToolRegistry() @@ -11428,7 +11436,7 @@ class QwenAgent implements Agent { status: registry.get(taskId)?.status, }; } finally { - this.startingWorkflowTaskIds.delete(startClaim); + this.mutatingWorkflowTaskIds.delete(mutationClaim); } } const changed = diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 21ca0b2112c..f3f4930bbdd 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -495,6 +495,7 @@ describe('Session', () => { resolvePendingApproval: ReturnType; get: ReturnType; getHandle: ReturnType; + removeTerminal: ReturnType; list: ReturnType; }; let mockGoalRuntime: { @@ -716,6 +717,7 @@ describe('Session', () => { resolvePendingApproval: vi.fn().mockResolvedValue(true), get: vi.fn().mockReturnValue(undefined), getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), }; @@ -2378,6 +2380,53 @@ describe('Session', () => { ]); }); + it('drops persisted workflow history deleted by another session', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + session = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + + await expect( + deletingSession.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await session.refreshWorkflowHistory(); + + expect(session.getWorkflowHistory()).toEqual([]); + }); + it('keeps cached history on disk failure and removes it after deletion', async () => { session.dispose(); const onActiveWorkChanged = vi.fn(); @@ -2477,6 +2526,42 @@ describe('Session', () => { ); }); + it('stops deletion when a retry activates the run during refresh', async () => { + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishRefresh!: () => void; + listWorkflowSnapshotsSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = () => resolve([snapshot]); + }), + ); + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await vi.waitFor(() => + expect(listWorkflowSnapshotsSpy).toHaveBeenCalledOnce(), + ); + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'running' }); + finishRefresh(); + + await expect(deletion).resolves.toBe(false); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + it('rejects history deletion while the workflow is active', async () => { mockWorkflowRunRegistry.get.mockReturnValue({ status: 'paused' }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index a4f3344dc37..88d6b8d05b3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1888,6 +1888,10 @@ export class Session implements SessionContext { #statusChangeCallback: (() => void) | undefined; #workflowStatusChangeCallback: ((entry?: WorkflowTask) => void) | undefined; private workflowHistory: WorkflowSnapshot[]; + private readonly unpersistedWorkflowHistory = new Map< + string, + WorkflowSnapshot + >(); #shellStatusChangeCallback: (() => void) | undefined; private readonly workflowApprovalAbortController = new AbortController(); private activeTodoPlanRevision?: { @@ -3170,26 +3174,46 @@ export class Session implements SessionContext { async refreshWorkflowHistory(): Promise { const persisted = await listWorkflowSnapshots(this.config); const byRunId = new Map( - this.workflowHistory.map((snapshot) => [snapshot.runId, snapshot]), + persisted.map((snapshot) => [snapshot.runId, snapshot]), ); - for (const snapshot of persisted) byRunId.set(snapshot.runId, snapshot); + for (const [runId, snapshot] of this.unpersistedWorkflowHistory) { + const stored = byRunId.get(runId); + if ( + stored?.startTime === snapshot.startTime && + stored.endTime === snapshot.endTime + ) { + this.unpersistedWorkflowHistory.delete(runId); + } else { + byRunId.set(runId, snapshot); + } + } this.workflowHistory = [...byRunId.values()] .sort((a, b) => b.startTime - a.startTime) .slice(0, MAX_RETAINED_SNAPSHOTS); + this.#pruneUnpersistedWorkflowHistory(); return this.workflowHistory; } async deleteWorkflowHistory(runId: string): Promise { const registry = this.config.getWorkflowRunRegistry(); - const entry = registry.get(runId); - if (entry && !isTerminalWorkflowStatus(entry.status)) return false; + const isDeletable = (): boolean => { + const current = registry.get(runId); + return !current || isTerminalWorkflowStatus(current.status); + }; + if (!isDeletable()) return false; const handle = registry.getHandle(runId); - if (handle) await handle.completion; + if (handle) { + await handle.completion; + if (!isDeletable()) return false; + } await this.refreshWorkflowHistory(); + if (!isDeletable()) return false; if (!this.workflowHistory.some((item) => item.runId === runId)) { return false; } if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; + registry.removeTerminal(runId); + this.unpersistedWorkflowHistory.delete(runId); this.workflowHistory = this.workflowHistory.filter( (item) => item.runId !== runId, ); @@ -3200,12 +3224,25 @@ export class Session implements SessionContext { #rememberWorkflowHistory(entry: WorkflowTask): void { if (!isTerminalWorkflowStatus(entry.status)) return; const snapshot = toSnapshot(entry); + this.unpersistedWorkflowHistory.set(snapshot.runId, snapshot); this.workflowHistory = [ snapshot, ...this.workflowHistory.filter((item) => item.runId !== entry.runId), ] .sort((a, b) => b.startTime - a.startTime) .slice(0, MAX_RETAINED_SNAPSHOTS); + this.#pruneUnpersistedWorkflowHistory(); + } + + #pruneUnpersistedWorkflowHistory(): void { + const retainedRunIds = new Set( + this.workflowHistory.map((item) => item.runId), + ); + for (const runId of this.unpersistedWorkflowHistory.keys()) { + if (!retainedRunIds.has(runId)) { + this.unpersistedWorkflowHistory.delete(runId); + } + } } shouldHintAskUserQuestionRestore(): boolean { diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index 54fdc2f9817..b38265c00d9 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -817,6 +817,32 @@ describe('WorkflowRunRegistry', () => { expect(entry.notified).toBe(false); }); + it('removes only terminal entries without live handles', () => { + const r = new WorkflowRunRegistry(); + const running = r.register(reg('wf_running')); + const terminal = r.register(reg('wf_terminal')); + const held = r.register(reg('wf_held')); + const handle = { + runId: held.runId, + abort: vi.fn(), + } as unknown as WorkflowRunHandle; + r.attachHandle(handle); + r.fail(terminal.runId, 'failed', 2_000); + r.fail(held.runId, 'failed', 2_000); + const callback = vi.fn(); + r.setStatusChangeCallback(callback); + + expect(r.removeTerminal(running.runId)).toBe(false); + expect(r.removeTerminal(held.runId)).toBe(false); + expect(r.removeTerminal(terminal.runId)).toBe(true); + + expect(r.get(running.runId)).toBe(running); + expect(r.get(held.runId)).toBe(held); + expect(r.get(terminal.runId)).toBeUndefined(); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(undefined); + }); + it('rejects a duplicate run id until its owner handle is released', () => { const r = new WorkflowRunRegistry(); const runId = 'wf_collision'; diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 45455b74883..77d3d000821 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -1164,6 +1164,21 @@ export class WorkflowRunRegistry { return this.entries.get(runId); } + removeTerminal(runId: string): boolean { + const entry = this.entries.get(runId); + if ( + !entry || + !isTerminalWorkflowStatus(entry.status) || + this.handles.has(runId) + ) { + return false; + } + this.rejectPendingApprovals(runId); + this.entries.delete(runId); + this.emitStatusChange(); + return true; + } + setLineage( runId: string, sourceRunId: string, @@ -1352,7 +1367,7 @@ export class WorkflowRunRegistry { } } - private emitStatusChange(entry: WorkflowTask): void { + private emitStatusChange(entry?: WorkflowTask): void { if (!this.statusChangeCallback) return; try { this.statusChangeCallback(entry); From 3497d0d19f3d60a8a2cfbeaea4081012b54d398e Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Tue, 25 Aug 2026 07:00:04 +0000 Subject: [PATCH 07/23] fix(serve): enforce workspace trust on the daemon Workflow surfaces (#9546) --- packages/cli/src/serve/acp-http/dispatch.ts | 18 +++- .../cli/src/serve/acp-http/transport.test.ts | 80 ++++++++++++++++- packages/cli/src/serve/routes/session.ts | 15 +++- packages/cli/src/serve/server.test.ts | 85 ++++++++++++++++++- .../cli/src/serve/workflow-session-gate.ts | 23 +++++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/serve/workflow-session-gate.ts diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index f9ba068e111..07289883170 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -104,6 +104,7 @@ import { setupGithubEventData, } from '../routes/workspace-setup-github.js'; import { parseWorkspaceVoiceUpdateParams } from '../routes/workspace-voice.js'; +import { redactWorkflowsFromSupportedCommands } from '../workflow-session-gate.js'; import { MAX_TRUST_REASON_LENGTH } from '../validation-limits.js'; import { publicErrorMessage, @@ -2871,10 +2872,14 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/supported_commands`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; + const status = + await this.bridge.getSessionSupportedCommandsStatus(sessionId); this.replyConn( conn, id, - await this.bridge.getSessionSupportedCommandsStatus(sessionId), + this.isWorkspaceTrusted() + ? status + : redactWorkflowsFromSupportedCommands(status), ); return; } @@ -3632,6 +3637,13 @@ export class AcpDispatcher { } return; } + if (kind === 'workflow' && !this.isWorkspaceTrusted()) { + this.replyConn(conn, id, { + cancelled: false, + reason: 'disabled', + }); + return; + } const result = await this.bridge.cancelSessionTask( sessionId, taskId, @@ -3675,6 +3687,10 @@ export class AcpDispatcher { } return; } + if (!this.isWorkspaceTrusted()) { + this.replyConn(conn, id, { changed: false }); + return; + } const result = await this.bridge.controlSessionWorkflowTask( sessionId, taskId, diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 463e2a6e61a..89ded7c1406 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -20,6 +20,7 @@ import type { BridgeEvent, SessionReplaySnapshot, } from '@qwen-code/acp-bridge/eventBus'; +import type { ServeSessionSupportedCommandsStatus } from '@qwen-code/acp-bridge/status'; import { SessionArtifactAuthorizationError, SessionArtifactValidationError, @@ -417,7 +418,9 @@ class FakeBridge { }, }; } - async getSessionSupportedCommandsStatus(sessionId: string) { + async getSessionSupportedCommandsStatus( + sessionId: string, + ): Promise { return { v: 1, sessionId, availableCommands: [], availableSkills: [] }; } /** Per-session in-memory pr bindings, mirroring the real bridge's @@ -8428,6 +8431,81 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('fails the Workflow surfaces closed for an untrusted workspace', async () => { + await restartServer({ primaryTrusted: false }); + bridge.getSessionSupportedCommandsStatus = async ( + sessionId: string, + ): Promise => ({ + v: 1, + sessionId, + availableCommands: [ + { name: 'init', description: 'Initialize', input: null }, + { name: 'workflows', description: 'Manage workflows', input: null }, + ], + availableSkills: [], + workflowsEnabled: true, + savedWorkflows: [{ name: 'slow-phases', source: 'project' as const }], + }); + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: '_qwen/session/supported_commands', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/tasks/cancel', + params: { sessionId: 'sess-1', taskId: 'wf-1', kind: 'workflow' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 62, + method: '_qwen/session/tasks/workflow_action', + params: { + sessionId: 'sess-1', + taskId: 'slow-phases', + action: 'run-saved', + }, + }); + const frames = await takeFrames(await streamRes, 6); + const byId = new Map( + frames + .filter( + (frame): frame is { id: number; result?: unknown } => + typeof frame === 'object' && + frame !== null && + 'id' in frame && + typeof frame.id === 'number', + ) + .map((frame) => [frame.id, frame]), + ); + expect(byId.get(60)).toMatchObject({ + result: { + workflowsEnabled: false, + savedWorkflows: [], + availableCommands: [{ name: 'init', description: 'Initialize' }], + }, + }); + expect(byId.get(61)).toMatchObject({ + result: { cancelled: false, reason: 'disabled' }, + }); + expect(byId.get(62)).toMatchObject({ result: { changed: false } }); + expect(bridge.lastCancelledTask).toBeUndefined(); + expect(bridge.lastWorkflowAction).toBeUndefined(); + }); + it('_qwen/session/lsp returns status', async () => { const connId = await initialize(); const streamRes = openStream(connId); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 0776f3e3743..cb5d0687f84 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -154,6 +154,7 @@ import { sendUntrustedWorkspaceResponse, sendWorkspaceRuntimeUnavailable, } from '../workspace-route-runtime.js'; +import { redactWorkflowsFromSupportedCommands } from '../workflow-session-gate.js'; import type { WorkspaceEntry, WorkspaceRegistry, @@ -4236,10 +4237,14 @@ export function registerSessionRoutes( withOwnerReadSession( 'GET /session/:id/supported-commands', async (_req, res, sessionId, runtime) => { + const status = + await runtime.bridge.getSessionSupportedCommandsStatus(sessionId); res .status(200) .json( - await runtime.bridge.getSessionSupportedCommandsStatus(sessionId), + runtime.trusted + ? status + : redactWorkflowsFromSupportedCommands(status), ); }, ), @@ -4416,6 +4421,10 @@ export function registerSessionRoutes( }); return; } + if (kind === 'workflow' && !runtime.trusted) { + res.status(200).json({ cancelled: false, reason: 'disabled' }); + return; + } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; res @@ -4460,6 +4469,10 @@ export function registerSessionRoutes( }); return; } + if (!runtime.trusted) { + res.status(200).json({ changed: false }); + return; + } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; res diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index e9aa7f69ffe..437e909b29b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -9110,7 +9110,7 @@ describe('createServeApp', () => { const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const contextRes = await request(app) @@ -9414,7 +9414,7 @@ describe('createServeApp', () => { const app = createServeApp( { ...tokenOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const res = await request(app) @@ -9447,7 +9447,7 @@ describe('createServeApp', () => { const app = createServeApp( { ...tokenOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const pauseRes = await request(app) @@ -9509,6 +9509,85 @@ describe('createServeApp', () => { ]); }); + it('fails the Workflow surfaces closed for an untrusted primary workspace', async () => { + const bridge = fakeBridge({ + sessionSupportedCommandsImpl: async (sessionId) => ({ + v: 1 as const, + sessionId, + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + _meta: { source: 'builtin' }, + }, + { + name: 'workflows', + description: 'Manage workflows', + input: null, + _meta: { source: 'builtin' }, + }, + ], + availableSkills: [], + workflowsEnabled: true, + savedWorkflows: [{ name: 'slow-phases', source: 'project' as const }], + }), + cancelSessionTaskImpl: async () => ({ cancelled: true }), + controlSessionWorkflowTaskImpl: async () => ({ + changed: true, + status: 'running', + taskId: 'wf-1', + }), + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: false, + bridge, + }), + ]), + }, + ); + + const commandsRes = await request(app) + .get('/session/s-1/supported-commands') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + const runSavedRes = await request(app) + .post('/session/s-1/tasks/slow-phases/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'run-saved' }); + const cancelRes = await request(app) + .post('/session/s-1/tasks/wf-1/cancel') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ kind: 'workflow' }); + + expect(commandsRes.status).toBe(200); + expect(commandsRes.body).toMatchObject({ + workflowsEnabled: false, + savedWorkflows: [], + }); + expect(commandsRes.body.availableCommands).toEqual([ + expect.objectContaining({ name: 'init' }), + ]); + expect(runSavedRes.status).toBe(200); + expect(runSavedRes.body).toEqual({ changed: false }); + expect(cancelRes.status).toBe(200); + expect(cancelRes.body).toEqual({ cancelled: false, reason: 'disabled' }); + expect(bridge.controlSessionWorkflowTaskCalls).toEqual([]); + expect(bridge.cancelSessionTaskCalls).toEqual([]); + }); + it.each([ ['agent%3A8', 'agent:8'], ['agent%2F8', 'agent/8'], diff --git a/packages/cli/src/serve/workflow-session-gate.ts b/packages/cli/src/serve/workflow-session-gate.ts new file mode 100644 index 00000000000..5b69c10beba --- /dev/null +++ b/packages/cli/src/serve/workflow-session-gate.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ServeSessionSupportedCommandsStatus } from '@qwen-code/acp-bridge/status'; + +// The daemon's workspace trust verdict never reaches the ACP child's +// workflow gate, so the daemon boundary redacts the surfaces itself with the +// same fail-closed shape the child produces when its own gate denies them. +export function redactWorkflowsFromSupportedCommands( + status: ServeSessionSupportedCommandsStatus, +): ServeSessionSupportedCommandsStatus { + return { + ...status, + availableCommands: status.availableCommands.filter( + (command) => command.name !== 'workflows', + ), + workflowsEnabled: false, + savedWorkflows: [], + }; +} From c25a5f97ac265ddb9febe7d62d493628ff5f0651 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 25 Aug 2026 16:29:36 +0000 Subject: [PATCH 08/23] fix(serve): close cross-session workflow deletion races and untrusted task reads (#9546) - Session history merge now treats a persisted snapshot as authoritative over a stale callback cache, and retires the cache once the runner confirms the snapshot write (new registry snapshot-persisted hook), so a sibling's deletion is not resurrected on refresh. - History deletion consults every sibling session's run registry (live entries and settling handles) before deleting from the shared store, and a successful deletion purges sibling terminal entries so retries cannot re-persist a deleted run. - Workflow holds mirror the registry's hasRunningEntries: paused runs no longer pin the session indefinitely. - The includeWorkflows opt-in is gated on workspace trust at the daemon boundary in both the ACP-HTTP dispatch and the REST tasks route, matching the fail-closed shape of the other workflow surfaces. --- .../cli/src/acp-integration/acpAgent.test.ts | 88 +++++++ packages/cli/src/acp-integration/acpAgent.ts | 38 ++- .../session/Session.review-lease.test.ts | 1 + .../acp-integration/session/Session.test.ts | 231 +++++++++++++++++- .../src/acp-integration/session/Session.ts | 39 ++- .../session/Session.worktree.test.ts | 1 + packages/cli/src/serve/acp-http/dispatch.ts | 6 +- .../cli/src/serve/acp-http/transport.test.ts | 28 ++- packages/cli/src/serve/routes/session.ts | 6 +- packages/cli/src/serve/server.test.ts | 8 + .../agents/runtime/workflow-runner.test.ts | 35 +++ .../src/agents/runtime/workflow-runner.ts | 11 +- .../core/src/agents/workflow-run-registry.ts | 27 ++ packages/core/src/agents/workflow-snapshot.ts | 9 +- 14 files changed, 508 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index fc08d152475..c234920913e 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -289,6 +289,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ await importOriginal() ).toolResultPartDiagnosticValues, preloadContentGenerator: mockPreloadContentGenerator, + isTerminalWorkflowStatus: ( + await importOriginal() + ).isTerminalWorkflowStatus, listSavedWorkflows: mockListSavedWorkflows, listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, @@ -10940,6 +10943,91 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('wires history-deletion liveness across every sibling session registry', async () => { + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const innerConfigA = await setupSessionMocks(sessionIdA); + const registryA = { + get: vi.fn().mockReturnValue({ runId: 'wf_shared', status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }; + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryA), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + // Every session in this child shares the workflow store but keeps a + // private registry; the second session must see the first's runs. + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), + }), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const sessionCalls = vi.mocked(Session).mock.calls; + expect(sessionCalls).toHaveLength(2); + const predicateA = sessionCalls[0][7] as (runId: string) => boolean; + const predicateB = sessionCalls[1][7] as (runId: string) => boolean; + + // Session A still runs wf_shared: B's deletion view must refuse it. + expect(predicateB('wf_shared')).toBe(true); + expect(registryA.get).toHaveBeenCalledWith('wf_shared'); + // A's own registry is consulted by Session itself, never by its + // sibling predicate. + expect(predicateA('wf_shared')).toBe(false); + // Once A's run settles terminal, B may delete. + registryA.get.mockReturnValue({ runId: 'wf_shared', status: 'failed' }); + expect(predicateB('wf_shared')).toBe(false); + // A handle that outlives the terminal transition (snapshot write in + // flight) still blocks the sibling deletion. + registryA.getHandle.mockReturnValue({ completion: Promise.resolve() }); + expect(predicateB('wf_shared')).toBe(true); + + // Once session B's deletion succeeds, the sibling registries lose the + // terminal entry too — or a retry from the sibling would resurrect + // the deleted run. + registryA.getHandle.mockReturnValue(undefined); + registryA.get.mockReturnValue({ runId: 'wf_shared', status: 'failed' }); + lastSessionMock!.deleteWorkflowHistory.mockResolvedValueOnce(true); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdB, + taskId: 'wf_shared', + action: 'delete-history', + }), + ).resolves.toEqual({ changed: true }); + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledWith( + 'wf_shared', + ); + expect(registryA.removeTerminal).toHaveBeenCalledWith('wf_shared'); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('returns not_running for stopped task cancellation', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 58602792613..72e24569c0a 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -136,6 +136,7 @@ import { type ToolInvocationGuard, type WorkflowParams, type WorkflowToolResult, + isTerminalWorkflowStatus, listSavedWorkflows, listWorkflowSnapshots, type TurnResultRecordPayload, @@ -10463,7 +10464,20 @@ class QwenAgent implements Agent { } this.mutatingWorkflowTaskIds.add(mutationClaim); try { - return { changed: await session.deleteWorkflowHistory(taskId) }; + const changed = await session.deleteWorkflowHistory(taskId); + if (changed) { + // Every session shares the one store: drop the sibling + // registries' terminal entries too, or a retry from a + // sibling re-persists the just-deleted run. + for (const [siblingId, sibling] of this.sessions) { + if (siblingId === sessionId) continue; + sibling + .getConfig() + .getWorkflowRunRegistry() + .removeTerminal(taskId); + } + } + return { changed }; } finally { this.mutatingWorkflowTaskIds.delete(mutationClaim); } @@ -12430,6 +12444,27 @@ class QwenAgent implements Agent { config.setFileSystemService(acpFileSystemService); } + /** + * All sessions in this child share one workflow snapshot store but each + * keeps a private run registry, so one session's history deletion must + * see every sibling registry — or it can delete a run another session + * is still executing or settling. A handle outlives the terminal + * transition until the snapshot write lands, so it blocks too. + */ + private isWorkflowRunLiveOutsideSession( + excludeSessionId: string, + runId: string, + ): boolean { + for (const [sessionId, session] of this.sessions) { + if (sessionId === excludeSessionId) continue; + const registry = session.getConfig().getWorkflowRunRegistry(); + const entry = registry.get(runId); + if (entry && !isTerminalWorkflowStatus(entry.status)) return true; + if (registry.getHandle(runId)) return true; + } + return false; + } + private async createAndStoreSession( config: Config, settings: LoadedSettings, @@ -12480,6 +12515,7 @@ class QwenAgent implements Agent { (operation) => this.runExclusiveHistoryMutation(sessionId, operation), () => this.activeWorkReporter?.notifyChanged(), workflowHistory, + (runId) => this.isWorkflowRunLiveOutsideSession(sessionId, runId), ); let published = false; try { diff --git a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts index a88d77c4d3a..f88ddd7775e 100644 --- a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts +++ b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts @@ -167,6 +167,7 @@ describe('Session review-worktree lease sweep', () => { setStatusChangeCallback: vi.fn(), clearStatusChangeCallback: vi.fn(), setCompletionCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), }), setSubSessionSpawner: vi.fn(), diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 1baa5a3b0d1..8a3da98fabb 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -494,6 +494,7 @@ describe('Session', () => { let mockWorkflowRunRegistry: { setCompletionCallback: ReturnType; setStatusChangeCallback: ReturnType; + setSnapshotPersistedCallback: ReturnType; clearStatusChangeCallback: ReturnType; setApprovalRequestCallback: ReturnType; resolvePendingApproval: ReturnType; @@ -721,6 +722,7 @@ describe('Session', () => { mockWorkflowRunRegistry = { setCompletionCallback: vi.fn(), setStatusChangeCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), clearStatusChangeCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), resolvePendingApproval: vi.fn().mockResolvedValue(true), @@ -1118,18 +1120,24 @@ describe('Session', () => { session.dispose(); }); - it('holds each non-terminal workflow run', () => { + it('holds executing workflow runs but never paused ones', () => { mockWorkflowRunRegistry.list.mockReturnValue([ { runId: 'wf-running', status: 'running' }, + { runId: 'wf-pausing', status: 'pausing' }, { runId: 'wf-paused', status: 'paused' }, { runId: 'wf-complete', status: 'completed' }, ]); createReportingSession(); - expect(holdIds('workflow')).toEqual(['wf-running', 'wf-paused']); + // Mirrors the registry's hasRunningEntries(): a paused run executes + // nothing and no backstop would ever release the hold, so it must + // not pin the session forever. + expect(holdIds('workflow')).toEqual(['wf-running', 'wf-pausing']); expect(session.isIdle()).toBe(false); - mockWorkflowRunRegistry.list.mockReturnValue([]); + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'wf-paused', status: 'paused' }, + ]); expect(session.collectActiveWorkHolds()).toEqual([]); expect(session.isIdle()).toBe(true); session.dispose(); @@ -2443,6 +2451,223 @@ describe('Session', () => { expect(session.getWorkflowHistory()).toEqual([]); }); + it('lets a newer persisted snapshot win over a stale cached one', async () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_reused', + kind: 'workflow', + runId: 'wf_reused', + description: 'Stale cached run', + meta: null, + status: 'failed', + startTime: 1_000, + endTime: 2_000, + error: 'old error', + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([ + { + runId: 'wf_reused', + meta: null, + status: 'completed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 3_000, + endTime: 4_000, + }, + ]); + + await session.refreshWorkflowHistory(); + + // The persisted copy is the newer authoritative projection of the + // reused runId; the stale callback cache must not shadow it. + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ + runId: 'wf_reused', + status: 'completed', + startTime: 3_000, + endTime: 4_000, + }), + ]); + }); + + it('does not resurrect a sibling-deleted run cached through the status callback', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const observingSession = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + // The observing session caches the terminal run through the + // status-change callback, exactly as it lands before the runner's + // snapshot write. + const callback = + mockWorkflowRunRegistry.setStatusChangeCallback.mock.calls.at( + -1, + )?.[0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_abcd', + kind: 'workflow', + runId: 'wf_abcd', + description: 'Callback-cached run', + meta: null, + status: 'failed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + expect(observingSession.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_abcd' }), + ]); + // The runner then persists the snapshot; the registry notification + // retires the observing session's unpersisted cache entry. + const snapshotPersisted = + mockWorkflowRunRegistry.setSnapshotPersistedCallback.mock.calls.at( + -1, + )?.[0] as ((runId: string) => void) | undefined; + snapshotPersisted?.('wf_abcd'); + + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect( + deletingSession.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await observingSession.refreshWorkflowHistory(); + + expect(observingSession.getWorkflowHistory()).toEqual([]); + }); + + it('rejects history deletion while a sibling session still owns the run', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_deadbeef', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const siblingRegistry = { + get: vi.fn().mockReturnValue({ runId: 'wf_deadbeef', status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + }; + const isWorkflowRunLiveInSiblingSession = (runId: string): boolean => { + const entry = siblingRegistry.get(runId) as + | { status: core.WorkflowStatus } + | undefined; + if (entry && !core.isTerminalWorkflowStatus(entry.status)) return true; + return siblingRegistry.getHandle(runId) !== undefined; + }; + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + isWorkflowRunLiveInSiblingSession, + ); + + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + false, + ); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + + // Once the sibling run settles terminal, deletion proceeds. + siblingRegistry.get.mockReturnValue({ + runId: 'wf_deadbeef', + status: 'failed', + }); + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + true, + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + it('keeps cached history on disk failure and removes it after deletion', async () => { session.dispose(); const onActiveWorkChanged = vi.fn(); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 7d09a2aed04..40ec948e289 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1995,6 +1995,15 @@ export class Session implements SessionContext { */ private readonly onActiveWorkChanged?: () => void, workflowHistory: readonly WorkflowSnapshot[] = [], + /** + * Reports whether another session in this process owns a live or + * still-settling registry entry for the run. Every session here shares + * one on-disk workflow store but keeps a private registry, so history + * deletion must consult all of them, not just this session's. + */ + private readonly isWorkflowRunLiveInSiblingSession: ( + runId: string, + ) => boolean = () => false, ) { this.sessionId = id; this.workflowHistory = [...workflowHistory]; @@ -3224,13 +3233,17 @@ export class Session implements SessionContext { ); for (const [runId, snapshot] of this.unpersistedWorkflowHistory) { const stored = byRunId.get(runId); - if ( - stored?.startTime === snapshot.startTime && - stored.endTime === snapshot.endTime - ) { - this.unpersistedWorkflowHistory.delete(runId); - } else { + if (stored === undefined) { + // Never persisted (write pending or failed): keep the cached + // projection visible. Once persistence is observed the entry is + // retired via the snapshot-persisted callback, so absence here + // afterwards means the run was deleted and must stay gone. byRunId.set(runId, snapshot); + } else { + // A persisted copy is the newer authoritative projection: the + // runId settled (possibly re-run in another session), so a stale + // cache must not shadow it. + this.unpersistedWorkflowHistory.delete(runId); } } this.workflowHistory = [...byRunId.values()] @@ -3243,6 +3256,7 @@ export class Session implements SessionContext { async deleteWorkflowHistory(runId: string): Promise { const registry = this.config.getWorkflowRunRegistry(); const isDeletable = (): boolean => { + if (this.isWorkflowRunLiveInSiblingSession(runId)) return false; const current = registry.get(runId); return !current || isTerminalWorkflowStatus(current.status); }; @@ -3394,7 +3408,10 @@ export class Session implements SessionContext { holds.push({ category: 'shell', id: 'background-shells' }); } for (const task of this.config.getWorkflowRunRegistry().list()) { - if (!isTerminalWorkflowStatus(task.status)) { + // Mirror the registry's hasRunningEntries(): a paused run executes + // nothing and no backstop would ever release the hold, so it must + // not pin the session the way executing work does. + if (task.status === 'running' || task.status === 'pausing') { holds.push({ category: 'workflow', id: task.runId }); } } @@ -3582,6 +3599,9 @@ export class Session implements SessionContext { this.#shellStatusChangeCallback = undefined; } this.config.getWorkflowRunRegistry().setCompletionCallback(undefined); + this.config + .getWorkflowRunRegistry() + .setSnapshotPersistedCallback(undefined); if (this.#workflowStatusChangeCallback) { this.config .getWorkflowRunRegistry() @@ -8344,6 +8364,11 @@ export class Session implements SessionContext { workflowRegistry.setStatusChangeCallback( this.#workflowStatusChangeCallback, ); + workflowRegistry.setSnapshotPersistedCallback((runId) => { + // The run is safely on disk now; drop the unpersisted copy so a + // deletion by another session cannot resurrect it on refresh. + this.unpersistedWorkflowHistory.delete(runId); + }); workflowRegistry.setCompletionCallback((displayText, modelText, meta) => { this.#enqueueBackgroundNotification({ displayText, diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index fa0f73d3cdb..dbbef5a2674 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -187,6 +187,7 @@ describe('Session.pendingWorktreeNotice', () => { setStatusChangeCallback: vi.fn(), clearStatusChangeCallback: vi.fn(), setCompletionCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), }), setSubSessionSpawner: vi.fn(), diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 68172cb29c9..02b35a6903e 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -3616,7 +3616,11 @@ export class AcpDispatcher { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; const result = await this.bridge.getSessionTasksStatus(sessionId, { - includeWorkflows: params['includeWorkflows'] === true, + // Same fail-closed shape as the workflow control surfaces: + // opting in here leaks strictly more than the redacted + // supported-commands surface on an untrusted workspace. + includeWorkflows: + this.isWorkspaceTrusted() && params['includeWorkflows'] === true, }); this.replyConn(conn, id, result as unknown); return; diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 5de300822ad..51125a81397 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -552,7 +552,12 @@ class FakeBridge { async getSessionContextUsageStatus(sessionId: string) { return { sessionId, used: 100, total: 1000 }; } - async getSessionTasksStatus(sessionId: string) { + lastSessionTasksOptions: { includeWorkflows?: boolean } | undefined; + async getSessionTasksStatus( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ) { + this.lastSessionTasksOptions = opts; return { sessionId, tasks: [] }; } lastCancelledTask: @@ -8428,12 +8433,15 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { jsonrpc: '2.0', id: 57, method: '_qwen/session/tasks', - params: { sessionId: 'sess-1' }, + params: { sessionId: 'sess-1', includeWorkflows: true }, }); const frames = await takeFrames(await streamRes, 2); expect(frames[1]).toMatchObject({ result: { sessionId: 'sess-1', tasks: [] }, }); + expect(bridge.lastSessionTasksOptions).toEqual({ + includeWorkflows: true, + }); }); it('_qwen/session/tasks/cancel forwards the task and trusted client context', async () => { @@ -8550,7 +8558,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { action: 'run-saved', }, }); - const frames = await takeFrames(await streamRes, 6); + await post(connId, { + jsonrpc: '2.0', + id: 63, + method: '_qwen/session/tasks', + params: { sessionId: 'sess-1', includeWorkflows: true }, + }); + const frames = await takeFrames(await streamRes, 7); const byId = new Map( frames .filter( @@ -8573,6 +8587,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { result: { cancelled: false, reason: 'disabled' }, }); expect(byId.get(62)).toMatchObject({ result: { changed: false } }); + expect(byId.get(63)).toMatchObject({ + result: { sessionId: 'sess-1', tasks: [] }, + }); + // The untrusted-workspace gate fail-closes the read path too: the + // includeWorkflows opt-in must not reach the child. + expect(bridge.lastSessionTasksOptions).toEqual({ + includeWorkflows: false, + }); expect(bridge.lastCancelledTask).toBeUndefined(); expect(bridge.lastWorkflowAction).toBeUndefined(); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 02bf16c1295..cd7521fd5ad 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -4366,7 +4366,11 @@ export function registerSessionRoutes( async (req, res, sessionId, runtime) => { res.status(200).json( await runtime.bridge.getSessionTasksStatus(sessionId, { - includeWorkflows: req.query['includeWorkflows'] === 'true', + // Same fail-closed shape as the workflow control surfaces: + // opting in here leaks strictly more than the redacted + // supported-commands surface on an untrusted workspace. + includeWorkflows: + runtime.trusted && req.query['includeWorkflows'] === 'true', }), ); }, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 3cc520e256d..c7d6295acd8 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -9572,6 +9572,10 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret') .send({ kind: 'workflow' }); + const tasksRes = await request(app) + .get('/session/s-1/tasks?includeWorkflows=true') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); expect(commandsRes.status).toBe(200); expect(commandsRes.body).toMatchObject({ @@ -9585,6 +9589,10 @@ describe('createServeApp', () => { expect(runSavedRes.body).toEqual({ changed: false }); expect(cancelRes.status).toBe(200); expect(cancelRes.body).toEqual({ cancelled: false, reason: 'disabled' }); + expect(tasksRes.status).toBe(200); + // The untrusted-workspace gate fail-closes the read path too: the + // includeWorkflows opt-in must not reach the child. + expect(bridge.sessionTasksOptions).toEqual([{ includeWorkflows: false }]); expect(bridge.controlSessionWorkflowTaskCalls).toEqual([]); expect(bridge.cancelSessionTaskCalls).toEqual([]); }); diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index 243477cde42..1f139903f53 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -278,6 +278,41 @@ describe('WorkflowRunner', () => { expect(observed.abortCount()).toBe(1); }); + it('notifies the registry when the terminal snapshot is persisted', async () => { + const { config, registry } = configWithRegistry(); + writeWorkflowSnapshotMock.mockResolvedValue(true); + const notify = vi.spyOn(registry, 'notifySnapshotPersisted'); + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return await agent("work")', + args: undefined, + dispatch: async () => 'done', + }); + await expect(handle.completion).resolves.toMatchObject({ ok: true }); + + expect(writeWorkflowSnapshotMock).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledWith(handle.runId); + }); + + it('does not notify when the snapshot write fails', async () => { + const { config, registry } = configWithRegistry(); + writeWorkflowSnapshotMock.mockResolvedValue(false); + const notify = vi.spyOn(registry, 'notifySnapshotPersisted'); + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return await agent("work")', + args: undefined, + dispatch: async () => 'done', + }); + await expect(handle.completion).resolves.toMatchObject({ ok: true }); + + expect(writeWorkflowSnapshotMock).toHaveBeenCalledOnce(); + expect(notify).not.toHaveBeenCalled(); + }); + it('settles failure and caller cancellation through the same owner', async () => { const failed = configWithRegistry(); const failedObserved = observeSettlement(failed.registry); diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index 7fa318fc566..a49b4559004 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -347,7 +347,16 @@ export class WorkflowRunner { tokens_spent: entry.tokensSpent, duration_ms: (entry.endTime ?? entry.startTime) - entry.startTime, }); - await writeWorkflowSnapshot(config, entry); + const snapshotPersisted = await writeWorkflowSnapshot( + config, + entry, + ); + if (snapshotPersisted) { + // Lets the owning session retire its unpersisted history + // cache entry: once the run is safely on disk, a sibling's + // deletion must win over the stale in-memory copy. + registry?.notifySnapshotPersisted(entry.runId); + } await journal?.drain(); try { logWorkflowRun(config, telemetryEvent); diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 651051d3ced..7610c71fdb9 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -363,6 +363,14 @@ export type WorkflowApprovalRequestCallback = ( signal: AbortSignal, ) => void | Promise; +/** + * Fires when the runner has safely persisted a terminal run's snapshot to + * the shared store. The owning session uses this to retire its + * unpersisted history cache: once the run exists on disk, absence from + * the store means a deletion happened, not "not written yet". + */ +export type WorkflowSnapshotPersistedCallback = (runId: string) => void; + interface WorkflowApprovalRuntime { respond: AgentApprovalRequestEvent['respond']; requestController?: AbortController; @@ -379,6 +387,9 @@ export class WorkflowRunRegistry { private completionCallback: WorkflowRunCompletionCallback | undefined; private approvalChangeCallback: WorkflowApprovalChangeCallback | undefined; private approvalRequestCallback: WorkflowApprovalRequestCallback | undefined; + private snapshotPersistedCallback: + | WorkflowSnapshotPersistedCallback + | undefined; private readonly approvalRuntimes = new Map< string, WorkflowApprovalRuntime @@ -448,6 +459,22 @@ export class WorkflowRunRegistry { this.approvalRequestCallback = cb; } + setSnapshotPersistedCallback( + cb: WorkflowSnapshotPersistedCallback | undefined, + ): void { + this.snapshotPersistedCallback = cb; + } + + /** Called by the runner once a terminal run's snapshot is persisted. */ + notifySnapshotPersisted(runId: string): void { + if (!this.snapshotPersistedCallback) return; + try { + this.snapshotPersistedCallback(runId); + } catch (error) { + debugLogger.error('Failed to notify snapshot persistence:', error); + } + } + /** Fire the terminal-completion notification (best-effort). */ private emitNotification(entry: WorkflowTask): void { if (!this.notificationCallback) return; diff --git a/packages/core/src/agents/workflow-snapshot.ts b/packages/core/src/agents/workflow-snapshot.ts index 5f68800e9ac..bf8efb05942 100644 --- a/packages/core/src/agents/workflow-snapshot.ts +++ b/packages/core/src/agents/workflow-snapshot.ts @@ -114,14 +114,15 @@ function safeResult(result: unknown): unknown { * Write a run snapshot to `/workflows/.json`, then prune * the oldest snapshots beyond `MAX_RETAINED_SNAPSHOTS`. Best-effort: a write * failure is logged, not thrown (persistence is a convenience, not a - * correctness requirement). + * correctness requirement). Returns true when the snapshot file was written, + * so the caller can tell persistence apart from a swallowed failure. */ export async function writeWorkflowSnapshot( config: Config, task: WorkflowTask, -): Promise { +): Promise { const storage = config.storage; - if (!storage) return; + if (!storage) return false; try { // Project BEFORE the first await: the caller captures this at // settlement, but in-flight dispatches keep mutating the live @@ -136,8 +137,10 @@ export async function writeWorkflowSnapshot( 'utf8', ); await pruneSnapshots(dir); + return true; } catch (e) { debugLogger.warn(`writeWorkflowSnapshot failed for ${task.runId}: ${e}`); + return false; } } From b799ea63b1c68d316003742021ca11b9b74e21f3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 12:08:16 +0800 Subject: [PATCH 09/23] fix(cli): close the four cross-session workflow-history consistency holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses R5-9, R7-4, R7-5 and R7-10 — all four Criticals open on this PR. They are one family: a run's history can be deleted, retried, or listed from any session, and each gate had a different idea of which runs exist. R5-9 — the mutation claim is task-global, not session-scoped. Keyed `sessionId\0taskId`, it serialized nothing that mattered: every session shares one snapshot store. A sibling's retry passed canStart (`failed`, no handle), took its own per-session claim, then awaited journal load/compile before `register()`; a delete-history landing in that structural window found the run terminal and handle-less in every registry, removed the journal directory and snapshot, and answered `{changed: true}` — after which the retry re-registered and its settlement re-persisted the history the user was told was deleted. The claim is now keyed by taskId alone and taken by delete-history, retry, rerun and run-saved. `run-saved` keys off a saved workflow's NAME, so it claims in its own `saved\0` namespace rather than colliding with runIds. R7-4 — deletion tests membership against the uncapped merged set. `buildSessionTasksStatus` serializes every registry entry unconditionally while `refreshWorkflowHistory` truncates to MAX_RETAINED_SNAPSHOTS by startTime, so a long run that settled after ~30 newer ones started stayed listed via the registry but fell out of the window — terminal, handle-free, live in no sibling, and permanently undeletable. `refreshWorkflowHistory` now records the merged id set before the cap, and deletion gates on that, the registry, or the unpersisted cache. `deleteWorkflowSnapshot` already tolerates an absent target, so the wider gate cannot delete what is not there. R7-5 — snapshot retirement is a latch. The registry's dispatch-drain callbacks emit status changes on TERMINAL entries with no status gate, and in-flight dispatches keep draining across the snapshot write, so a terminal emission routinely landed after `notifySnapshotPersisted` had retired the cache entry — re-inserting the run as "never persisted". A sibling's deletion was then undone by the next refresh, which reads "absent on disk, present in cache" as a pending write and republishes. Persistence is now remembered per runId and `#rememberWorkflowHistory` returns early for members; the latch releases when the runId goes active again, so a genuine re-run is still cached. R7-10 — the liveness gate sees runs whose session is gone. It iterated `this.sessions` only, but close/kill/shutdown use force semantics and a background run owns a detached controller, so after `removeStoredSessionEntry` a still-settling run was invisible to the gate and unreachable by the delete handler's sibling `removeTerminal` loop: a sibling delete-history removed the LIVE run's journal and snapshot, and the orphan's settlement write recreated it. Two halves — `Session.dispose()` now aborts its workflow registry the way it already aborts the agent registry (before the callbacks are torn down), and the registry of a removed session is retained here until its runs drain, so the gate still answers across the settlement window an abort cannot compress to zero. Retention is bookkeeping: a Config that cannot answer is logged, never turned into a shutdown failure. Regressions, each mutation-checked against its own repair: - acpAgent: a sibling's parked retry makes delete-history answer `{changed: false}` without reaching the store, and the deletion goes through once the claim releases - acpAgent: a live run's registry stays visible to the gate across its session's close, and is dropped once the handle is released - Session: a status emission after `snapshotPersisted` no longer resurrects a sibling-deleted run (reverting the latch reproduces the reviewer's probe verbatim), and a re-registered runId is remembered again - Session: a run with the oldest startTime behind 30 newer snapshots is deletable - Session: dispose aborts the workflow registry before clearing its callbacks Verification: Session.test.ts 733 passed, acpAgent.test.ts 514 passed, `tsc --noEmit -p packages/cli` clean, eslint and prettier clean. The 14 `packages/cli/src/serve` failures (fast-path import boundary, capabilities-docs contract, workspace fs/agents/memory, conversation runtime ownership) reproduce identically on the unmodified head — base skew, untouched by this change. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1 --- .../cli/src/acp-integration/acpAgent.test.ts | 199 ++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 178 ++++++++++--- .../acp-integration/session/Session.test.ts | 250 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 71 ++++- 4 files changed, 664 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 260eb4cc3cf..5350b86a180 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11556,6 +11556,205 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('still sees a live workflow run whose owning session was closed', async () => { + // R7-10: the liveness gate iterated `this.sessions` only, but a run + // outlives its session's removal — close/kill/shutdown use force + // semantics and a background run owns a detached controller. Once + // `removeStoredSessionEntry` deleted the session, a still-settling run + // was invisible here and unreachable by the delete handler's sibling + // `removeTerminal` loop, so a sibling delete-history removed the LIVE + // run's journal and snapshot and answered `{changed: true}` — and the + // orphan's settlement write recreated the file it had just deleted. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_orphan1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId, status: 'failed' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const sessionMockA = lastSessionMock!; + + // Session B owns a run that is terminal but still settling: the + // handle lives until the snapshot write lands. + const entryB = { runId, status: 'failed' as const }; + const registryB = { + get: vi.fn().mockReturnValue(entryB), + getHandle: vi.fn().mockReturnValue({ completion: Promise.resolve() }), + removeTerminal: vi.fn().mockReturnValue(false), + hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([entryB]), + abortAll: vi.fn(), + }; + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryB), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const sessionCalls = vi.mocked(Session).mock.calls; + const predicateA = sessionCalls[0][7] as (runId: string) => boolean; + // While B is in the session map the gate already answered correctly. + expect(predicateA(runId)).toBe(true); + + // Force-close B. Before the fix the registry vanished with the map + // entry and the gate went blind for the rest of the settlement. + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { + sessionId: sessionIdB, + }); + expect(predicateA(runId)).toBe(true); + + // A delete-history from A must refuse for as long as the handle lives. + sessionMockA.deleteWorkflowHistory.mockResolvedValue(false); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdA, + taskId: runId, + action: 'delete-history', + }), + ).resolves.toEqual({ changed: false }); + + // Once the orphan settles and releases its handle the retained + // registry is pruned and the gate stops answering for it. + registryB.getHandle.mockReturnValue(undefined); + expect(predicateA(runId)).toBe(false); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it("serializes a sibling session's retry against a history deletion", async () => { + // R5-9: the mutual-exclusion claim used to be keyed `sessionId\0taskId`, + // which serialized nothing that mattered — all sessions share one + // snapshot store. A sibling's retry passes canStart (`failed`, no + // handle), takes its own per-session claim, then awaits journal + // load/compile before `register()`; a delete-history landing in that + // structural window saw the run terminal and handle-less in every + // registry, deleted the journal directory and snapshot, and answered + // `{changed: true}` — after which the retry re-registered and its + // settlement re-persisted the history the user was told was deleted. + // With a task-global claim one of the two must refuse. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_shared1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId, status: 'failed' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const sessionMockA = lastSessionMock!; + + // Session B parks its retry exactly where the race lived: past + // canStart, inside the await that precedes `register()`. + let releaseRetry!: () => void; + const retryGate = new Promise((resolve) => { + releaseRetry = resolve; + }); + const execute = vi.fn(async () => { + await retryGate; + return { llmContent: 'started' }; + }); + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + runId, + status: 'failed', + script: 'return await agent("x")', + args: undefined, + }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), + }), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' + ? { buildSessionOwnedBackground: vi.fn(() => ({ execute })) } + : undefined, + ), + }), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const retry = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId: sessionIdB, taskId: runId, action: 'retry' }, + ); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + + // Session A's deletion of the same run must refuse while B holds the + // claim — and must not reach the store at all. + sessionMockA.deleteWorkflowHistory.mockResolvedValue(true); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdA, + taskId: runId, + action: 'delete-history', + }), + ).resolves.toEqual({ changed: false }); + expect(sessionMockA.deleteWorkflowHistory).not.toHaveBeenCalled(); + + releaseRetry(); + await expect(retry).resolves.toMatchObject({ changed: true }); + + // Once the claim is released the deletion goes through normally. + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdA, + taskId: runId, + action: 'delete-history', + }), + ).resolves.toEqual({ changed: true }); + expect(sessionMockA.deleteWorkflowHistory).toHaveBeenCalledWith(runId); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('wires history-deletion liveness across every sibling session registry', async () => { const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index a41e695854b..c4491e970c5 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -136,6 +136,7 @@ import { type ToolInvocationGuard, type WorkflowParams, type WorkflowToolResult, + type WorkflowRunRegistry, isTerminalWorkflowStatus, listSavedWorkflows, listWorkflowSnapshots, @@ -3376,6 +3377,35 @@ class QwenAgent implements Agent { private sessions: Map = new Map(); private readonly historyMutationTails = new Map>(); private readonly startingSessionIds = new Set(); + /** + * R7-10: workflow registries of sessions already removed from + * `this.sessions` whose runs have not finished settling. Consulted by + * `isWorkflowRunLiveOutsideSession` so a delete-history cannot delete + * an orphaned live run out from under itself. Pruned as they drain. + */ + private readonly detachedWorkflowRegistries = new Set(); + /** + * R5-9: mutual exclusion for workflow task mutations, keyed by the task + * ALONE — never by session. All sessions in this child share one + * snapshot store, so a claim scoped to `sessionId\0taskId` serialized + * nothing that matters: a sibling session's retry of the same runId ran + * concurrently with a delete-history and neither saw the other. The + * retry passes `canStart` (`failed`, no handle), then awaits journal + * load/compile before `register()`; a delete landing in that structural + * window finds the run terminal and handle-less in every registry, + * removes the journal directory and snapshot, and answers + * `{changed: true}` — after which the retry's `register()` and its + * settlement re-persist the history the user was told was deleted. + * + * Every mutating action takes this claim (delete-history, retry, rerun, + * run-saved) and answers `{changed: false}` when it is already held. + * Once a retry has registered, `isWorkflowRunLiveOutsideSession` takes + * over and keeps deletion blocked for the life of the run. + * + * `run-saved` keys off a saved workflow's NAME rather than a runId, so + * it claims in its own namespace — a saved workflow named like a runId + * must not serialize against that run's history deletion. + */ private readonly mutatingWorkflowTaskIds = new Set(); private activePromptCalls = new Map>(); private workspaceMcpDiscoveryConfig: Config | undefined; @@ -4016,6 +4046,21 @@ class QwenAgent implements Agent { cleanupErrors.push(error); } this.sessions.delete(sessionId); + // R7-10: the registry outlives the session map entry while its runs + // settle. Keep it reachable so the liveness gate still sees them. + // Retention is bookkeeping, not cleanup: a Config that cannot answer + // must not turn a successful close into a shutdown failure. + try { + const registry = session.getConfig().getWorkflowRunRegistry?.(); + if (registry && QwenAgent.isWorkflowRegistryDraining(registry)) { + this.detachedWorkflowRegistries.add(registry); + } + } catch (error) { + debugLogger.warn( + `Session ${sessionId}: could not retain its workflow registry for liveness checks:`, + error, + ); + } // A Session missing from the next snapshot is how the daemon learns the // child released it — including when it never saw our close response. this.activeWorkReporter?.notifyChanged(); @@ -10919,7 +10964,9 @@ class QwenAgent implements Agent { if (!this.canUseWorkflowControls(config)) { return { changed: false }; } - const mutationClaim = `${sessionId}\0${taskId}`; + // Task-global, NOT session-scoped — see `mutatingWorkflowTaskIds`. + const mutationClaim = + action === 'run-saved' ? `saved\0${taskId}` : taskId; if (action === 'delete-history') { if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { return { changed: false }; @@ -10946,34 +10993,42 @@ class QwenAgent implements Agent { } const registry = config.getWorkflowRunRegistry(); if (action === 'run-saved') { - const savedWorkflow = (await listSavedWorkflows(config)).find( - (entry) => entry.name === taskId, - ); - if (!savedWorkflow) return { changed: false }; - const workflowTool = config - .getToolRegistry() - .getTool(ToolNames.WORKFLOW); - if (!isSessionOwnedWorkflowTool(workflowTool)) { - throw RequestError.invalidParams( - undefined, - 'The workflow tool is unavailable; cannot run this saved workflow.', + if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { + return { changed: false }; + } + this.mutatingWorkflowTaskIds.add(mutationClaim); + try { + const savedWorkflow = (await listSavedWorkflows(config)).find( + (entry) => entry.name === taskId, ); + if (!savedWorkflow) return { changed: false }; + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!isSessionOwnedWorkflowTool(workflowTool)) { + throw RequestError.invalidParams( + undefined, + 'The workflow tool is unavailable; cannot run this saved workflow.', + ); + } + const result = (await workflowTool + .buildSessionOwnedBackground({ + scriptPath: savedWorkflow.scriptPath, + }) + .execute(new AbortController().signal)) as WorkflowToolResult; + const startedTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + return startedTask + ? { + changed: true, + status: startedTask.status, + taskId: startedTask.runId, + } + : { changed: false }; + } finally { + this.mutatingWorkflowTaskIds.delete(mutationClaim); } - const result = (await workflowTool - .buildSessionOwnedBackground({ - scriptPath: savedWorkflow.scriptPath, - }) - .execute(new AbortController().signal)) as WorkflowToolResult; - const startedTask = result.workflowRunId - ? registry.get(result.workflowRunId) - : undefined; - return startedTask - ? { - changed: true, - status: startedTask.status, - taskId: startedTask.runId, - } - : { changed: false }; } const task = registry.get(taskId); if (!task) return { changed: false }; @@ -12923,6 +12978,25 @@ class QwenAgent implements Agent { * see every sibling registry — or it can delete a run another session * is still executing or settling. A handle outlives the terminal * transition until the snapshot write lands, so it blocks too. + * + * R7-10: iterating `this.sessions` alone left a blind spot the width of + * a whole run. A workflow can outlive its owning session's removal — + * explicit close/kill/shutdown use force semantics, and a background + * run owns a detached controller — so once `removeStoredSessionEntry` + * deleted the session, a still-settling run became invisible here and + * unreachable by the delete handler's sibling `removeTerminal` loop. A + * sibling `delete-history` then passed every check, removed the LIVE + * run's journal directory and snapshot, and answered `{changed: true}`; + * the orphan kept going and its settlement `writeWorkflowSnapshot` + * recreated the file — resurrection, plus a run whose journal was rm'd + * under it. + * + * The repair has two halves. `Session.dispose()` now aborts its + * workflow registry the way it already aborts the agent registry, so an + * orphan settles instead of running on; and the registry of a removed + * session stays reachable here until its runs drain, so the gate keeps + * answering for the settlement window that abort cannot compress to + * zero (the handle is released only after the snapshot write lands). */ private isWorkflowRunLiveOutsideSession( excludeSessionId: string, @@ -12930,14 +13004,56 @@ class QwenAgent implements Agent { ): boolean { for (const [sessionId, session] of this.sessions) { if (sessionId === excludeSessionId) continue; - const registry = session.getConfig().getWorkflowRunRegistry(); - const entry = registry.get(runId); - if (entry && !isTerminalWorkflowStatus(entry.status)) return true; - if (registry.getHandle(runId)) return true; + if ( + QwenAgent.isWorkflowRunLiveInRegistry( + session.getConfig().getWorkflowRunRegistry(), + runId, + ) + ) { + return true; + } + } + this.pruneDrainedWorkflowRegistries(); + for (const registry of this.detachedWorkflowRegistries) { + if (QwenAgent.isWorkflowRunLiveInRegistry(registry, runId)) return true; } return false; } + private static isWorkflowRunLiveInRegistry( + registry: WorkflowRunRegistry, + runId: string, + ): boolean { + const entry = registry.get(runId); + if (entry && !isTerminalWorkflowStatus(entry.status)) return true; + return registry.getHandle(runId) !== undefined; + } + + /** + * Registries of removed sessions are retained only while they still + * hold work — an aborted run keeps its handle until settlement releases + * it. Dropping drained ones keeps this from becoming a leak that grows + * with every closed session. + */ + private pruneDrainedWorkflowRegistries(): void { + for (const registry of this.detachedWorkflowRegistries) { + if (!QwenAgent.isWorkflowRegistryDraining(registry)) { + this.detachedWorkflowRegistries.delete(registry); + } + } + } + + /** Still holding work: an active entry, or a handle awaiting settlement. */ + private static isWorkflowRegistryDraining( + registry: WorkflowRunRegistry, + ): boolean { + if (registry.hasRunningEntries?.()) return true; + return ( + registry.list?.().some((entry) => registry.getHandle(entry.runId)) ?? + false + ); + } + private async createAndStoreSession( config: Config, settings: LoadedSettings, diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 311032d5f5d..139632ae7ad 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -512,6 +512,7 @@ describe('Session', () => { getHandle: ReturnType; removeTerminal: ReturnType; list: ReturnType; + abortAll: ReturnType; }; let mockGoalRuntime: { getSnapshot: ReturnType; @@ -748,6 +749,7 @@ describe('Session', () => { getHandle: vi.fn().mockReturnValue(undefined), removeTerminal: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + abortAll: vi.fn(), }; mockChatRecordingService = { @@ -3052,6 +3054,240 @@ describe('Session', () => { expect(observingSession.getWorkflowHistory()).toEqual([]); }); + it('keeps a sibling-deleted run buried when a late status emission lands after persistence', async () => { + // R7-5: the existing non-resurrection test fires the status callback + // only BEFORE snapshotPersisted, so it cannot see the real ordering. + // The registry's dispatch-drain callbacks emit on TERMINAL entries + // with no status gate, and in-flight dispatches keep draining across + // the snapshot write — so a terminal emission routinely lands AFTER + // retirement, re-inserting the run as "never persisted". A sibling's + // deletion was then undone by the next refresh: absent on disk but + // present in the stale cache reads as a pending write. Retirement has + // to be a latch. + session.dispose(); + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const observingSession = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const terminalEntry = { + id: 'wf_abcd', + kind: 'workflow' as const, + runId: 'wf_abcd', + description: 'Callback-cached run', + meta: null, + status: 'failed' as const, + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }; + const callback = + mockWorkflowRunRegistry.setStatusChangeCallback.mock.calls.at( + -1, + )?.[0] as (entry: core.WorkflowTask) => void; + const snapshotPersisted = + mockWorkflowRunRegistry.setSnapshotPersistedCallback.mock.calls.at( + -1, + )?.[0] as ((runId: string) => void) | undefined; + + callback(terminalEntry); + snapshotPersisted?.('wf_abcd'); + // A draining dispatch emits once more on the already-terminal entry, + // after retirement. This is the ordering the bug lived in. + callback(terminalEntry); + + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect( + deletingSession.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await observingSession.refreshWorkflowHistory(); + expect(observingSession.getWorkflowHistory()).toEqual([]); + }); + + it('re-remembers a run whose id is registered again after persistence', async () => { + // The latch must not be permanent: a retry reuses the runId, so once + // the entry goes active again its next settlement has to be cached + // like any other, or a genuine re-run would vanish from the session's + // projection until its own snapshot write lands. + session.dispose(); + const observingSession = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const base = { + id: 'wf_relive', + kind: 'workflow' as const, + runId: 'wf_relive', + description: 'Re-run', + meta: null, + startTime: 1_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }; + const callback = + mockWorkflowRunRegistry.setStatusChangeCallback.mock.calls.at( + -1, + )?.[0] as (entry: core.WorkflowTask) => void; + const snapshotPersisted = + mockWorkflowRunRegistry.setSnapshotPersistedCallback.mock.calls.at( + -1, + )?.[0] as ((runId: string) => void) | undefined; + + callback({ + ...base, + status: 'failed', + endTime: 2_000, + } as core.WorkflowTask); + snapshotPersisted?.('wf_relive'); + // The retry re-registers the same runId and runs. + callback({ + ...base, + status: 'running', + endTime: undefined, + } as core.WorkflowTask); + // Its own settlement must be cached again. + callback({ + ...base, + status: 'completed', + endTime: 3_000, + } as core.WorkflowTask); + + expect(observingSession.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_relive', status: 'completed' }), + ]); + }); + + it('deletes a run that fell out of the capped history window', async () => { + // R7-4: `buildSessionTasksStatus` serializes every registry entry + // unconditionally, but deletion gated on membership in the + // MAX_RETAINED_SNAPSHOTS window, which `refreshWorkflowHistory` + // truncates by startTime. A long run that settles after ~30 newer + // ones started stayed listed via the registry yet fell out of the + // window — terminal, handle-free, live in no sibling, and permanently + // undeletable. Membership must be tested against the uncapped set. + session.dispose(); + const target = { + runId: 'wf_oldest', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + // 30 strictly newer snapshots fill the window ahead of the target. + const newer = Array.from( + { length: core.MAX_RETAINED_SNAPSHOTS }, + (_, i) => ({ + ...target, + runId: `wf_newer${i}`, + startTime: 10_000 + i, + endTime: 20_000 + i, + }), + ); + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + mockWorkflowRunRegistry.get.mockReturnValue({ + runId: target.runId, + status: 'failed', + }); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([...newer, target]); + + await expect( + deletingSession.deleteWorkflowHistory(target.runId), + ).resolves.toBe(true); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + target.runId, + ); + expect(mockWorkflowRunRegistry.removeTerminal).toHaveBeenCalledWith( + target.runId, + ); + }); + it('rejects history deletion while a sibling session still owns the run', async () => { session.dispose(); const snapshot = { @@ -31606,6 +31842,20 @@ describe('Session', () => { expect( mockWorkflowRunRegistry.clearStatusChangeCallback, ).toHaveBeenCalledWith(expect.any(Function)); + // R7-10: mirror the agent registry. A workflow run that outlives + // its session's removal is invisible to the delete-history liveness + // gate, and its settlement snapshot write recreates history a + // sibling session just deleted. Abort must precede the callback + // teardown so the cancellation still reaches this session's own + // bookkeeping. + expect(mockWorkflowRunRegistry.abortAll).toHaveBeenCalled(); + expect( + mockWorkflowRunRegistry.abortAll.mock.invocationCallOrder.at(-1), + ).toBeLessThan( + mockWorkflowRunRegistry.setCompletionCallback.mock.invocationCallOrder.at( + -1, + )!, + ); }); it('aborts an active notificationAbortController and nulls the reference', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index edce02bd579..ccb8bf4c000 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2010,6 +2010,18 @@ export class Session implements SessionContext { #statusChangeCallback: (() => void) | undefined; #workflowStatusChangeCallback: ((entry?: WorkflowTask) => void) | undefined; private workflowHistory: WorkflowSnapshot[]; + /** + * R7-5: runIds whose snapshot write this session has observed. Latches + * `#rememberWorkflowHistory` off so a post-persistence status emission + * cannot resurrect a sibling-deleted run. See that method. + */ + private readonly persistedWorkflowRunIds = new Set(); + /** + * R7-4: every runId the last `refreshWorkflowHistory` merged, BEFORE the + * MAX_RETAINED_SNAPSHOTS cap. `workflowHistory` is the display window; + * this is what deletion tests membership against. + */ + private mergedWorkflowRunIds = new Set(); private readonly unpersistedWorkflowHistory = new Map< string, WorkflowSnapshot @@ -3407,6 +3419,10 @@ export class Session implements SessionContext { this.unpersistedWorkflowHistory.delete(runId); } } + // R7-4: the returned/stored history is the capped display window, but + // deletion must reason about the whole merged set — keep it before the + // slice rather than making callers re-derive it. + this.mergedWorkflowRunIds = new Set(byRunId.keys()); this.workflowHistory = [...byRunId.values()] .sort((a, b) => b.startTime - a.startTime) .slice(0, MAX_RETAINED_SNAPSHOTS); @@ -3429,12 +3445,29 @@ export class Session implements SessionContext { } await this.refreshWorkflowHistory(); if (!isDeletable()) return false; - if (!this.workflowHistory.some((item) => item.runId === runId)) { + // R7-4: membership must be tested against everything the client can + // SEE, not against the capped window. `buildSessionTasksStatus` + // serializes every registry entry unconditionally, while + // `refreshWorkflowHistory` truncates to MAX_RETAINED_SNAPSHOTS by + // startTime — so a long run that settles after ~30 newer ones started + // stays listed via the registry but falls out of the window, and the + // capped check answered `{changed: false}` forever. It was terminal, + // handle-free and live in no sibling: nothing but the window kept it + // undeletable. `deleteWorkflowSnapshot` already tolerates an absent + // target, so widening the gate cannot delete something that is not + // there. + if ( + !this.mergedWorkflowRunIds.has(runId) && + registry.get(runId) === undefined && + !this.unpersistedWorkflowHistory.has(runId) + ) { return false; } if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; registry.removeTerminal(runId); this.unpersistedWorkflowHistory.delete(runId); + this.mergedWorkflowRunIds.delete(runId); + this.persistedWorkflowRunIds.delete(runId); this.workflowHistory = this.workflowHistory.filter( (item) => item.runId !== runId, ); @@ -3443,7 +3476,28 @@ export class Session implements SessionContext { } #rememberWorkflowHistory(entry: WorkflowTask): void { - if (!isTerminalWorkflowStatus(entry.status)) return; + if (!isTerminalWorkflowStatus(entry.status)) { + // Back in an active state means this runId was registered afresh + // (a retry/resume reuses it), so its next settlement must be + // remembered again — release the R7-5 latch here rather than + // wiring a second registry callback for it. + this.persistedWorkflowRunIds.delete(entry.runId); + return; + } + // R7-5: retirement is a latch, not a one-shot. The registry's + // dispatch-drain callbacks (onAgentCompleted / onBudgetUpdated / + // onDispatchSettled) emit status changes on TERMINAL entries with no + // status gate, and in-flight dispatches keep draining across the + // snapshot write — so a terminal emission routinely lands AFTER + // `notifySnapshotPersisted` retired the cache entry. Without this + // guard each late emission re-inserted the run as "never persisted", + // and a sibling session's deletion was then undone by the next + // refresh: absent on disk but present in the stale cache reads as a + // pending write, so the deleted run was republished and stayed for + // the life of the session. Cleared on re-registration + // (`#forgetPersistedWorkflowRun`) so a genuine re-run of the same + // runId is remembered again. + if (this.persistedWorkflowRunIds.has(entry.runId)) return; const snapshot = toSnapshot(entry); this.unpersistedWorkflowHistory.set(snapshot.runId, snapshot); this.workflowHistory = [ @@ -3931,6 +3985,14 @@ export class Session implements SessionContext { shellRegistry.clearStatusChangeCallback(this.#shellStatusChangeCallback); this.#shellStatusChangeCallback = undefined; } + // R7-10: mirror the agent registry's treatment above. Without this a + // workflow outlives its session's removal — close/kill/shutdown use + // force semantics and a background run owns a detached controller — + // and an orphan that nothing can see keeps writing its snapshot, + // recreating history a sibling session just deleted. Abort BEFORE the + // callbacks are cleared so the cancellation still reaches this + // session's own bookkeeping. + this.config.getWorkflowRunRegistry().abortAll(); this.config.getWorkflowRunRegistry().setCompletionCallback(undefined); this.config .getWorkflowRunRegistry() @@ -8801,7 +8863,10 @@ export class Session implements SessionContext { ); workflowRegistry.setSnapshotPersistedCallback((runId) => { // The run is safely on disk now; drop the unpersisted copy so a - // deletion by another session cannot resurrect it on refresh. + // deletion by another session cannot resurrect it on refresh. The + // latch makes that retirement stick against the late terminal + // emissions draining dispatches still produce (R7-5). + this.persistedWorkflowRunIds.add(runId); this.unpersistedWorkflowHistory.delete(runId); }); workflowRegistry.setCompletionCallback((displayText, modelText, meta) => { From f42b2851727c1ac2375e414800afdd077f4713ac Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 14:39:20 +0800 Subject: [PATCH 10/23] fix(core): drop the duplicated telemetry-swap mock property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging upstream/main (053f17b775) clears the 9 `client.telemetrySwap` failures this branch had from predating #10220, but main carries its own TS1117 on the same file: two commits added `getToolRegistry` to the same object literal independently and neither saw the other. 032b9078e6 feat(serve): backfill session PR bindings ... (#9729) 8241905392 test(core): give the telemetry-swap client mock a getToolRegistry (#10220) Checking that file out from upstream/main here and running `tsc --noEmit -p packages/core` reproduces `client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so taking the merge unmodified would have traded 9 test failures for a build that never reaches the tests at all. Removed as part of the merge rather than left for a follow-up: keeps #10220's copy, which was added for this purpose and carries the explanation, and drops #9729's incidental one. main still needs the same removal — this only keeps it out of the branch. Verified after the merge: `tsc --noEmit` clean for both packages/core and packages/cli; client.telemetrySwap 10 passed, Session.test.ts and acpAgent.test.ts 1260 passed together. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1 --- packages/core/src/core/client.telemetrySwap.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/core/client.telemetrySwap.test.ts b/packages/core/src/core/client.telemetrySwap.test.ts index 9b9e2b8638f..2a19bc70754 100644 --- a/packages/core/src/core/client.telemetrySwap.test.ts +++ b/packages/core/src/core/client.telemetrySwap.test.ts @@ -89,9 +89,6 @@ function makeEnv() { const config: any = { getSessionId: () => sessionId, getResumedSessionData: () => resumedData, - // initialize() on a resumed session asks the skill tool to restore its - // loaded skills via config.getToolRegistry().getTool(...). - getToolRegistry: () => ({ getTool: () => undefined }), swap(id: string, data?: ResumedSessionData) { sessionId = id; resumedData = data; From 0b5d23c7b234ba956bb9f5e97eec7337934951e3 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:56:34 +0800 Subject: [PATCH 11/23] fix(workflows): guard pending run lifecycle --- .../cli/src/acp-integration/acpAgent.test.ts | 71 ++++++ packages/cli/src/acp-integration/acpAgent.ts | 226 ++++++++---------- .../acp-integration/session/Session.test.ts | 83 ++++++- .../src/acp-integration/session/Session.ts | 20 +- .../agents/runtime/workflow-runner.test.ts | 172 ++++++++++++- .../src/agents/runtime/workflow-runner.ts | 191 ++++++++------- .../src/agents/workflow-run-registry.test.ts | 100 ++++++++ .../core/src/agents/workflow-run-registry.ts | 118 ++++++++- 8 files changed, 761 insertions(+), 220 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 5350b86a180..c386b646d5e 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -293,6 +293,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ isTerminalWorkflowStatus: ( await importOriginal() ).isTerminalWorkflowStatus, + getWorkflowTaskMutationKey: ( + await importOriginal() + ).getWorkflowTaskMutationKey, + tryWithWorkflowTaskMutation: ( + await importOriginal() + ).tryWithWorkflowTaskMutation, listSavedWorkflows: mockListSavedWorkflows, listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, @@ -4046,6 +4052,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getModel: vi.fn().mockReturnValue('m'), storage: { getProjectRoot: vi.fn().mockReturnValue('/tmp'), + getWorkflowRunsDir: vi.fn().mockReturnValue('/tmp/workflows'), }, getProjectRoot: vi.fn().mockReturnValue('/tmp'), getTargetDir: vi.fn().mockReturnValue('/tmp'), @@ -11645,6 +11652,70 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('retains a closed session registry while a workflow is starting', async () => { + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_starting1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId, status: 'failed' }), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn().mockReturnValue(false), + removeTerminal: vi.fn().mockReturnValue(true), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + let starting = true; + const registryB = { + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn(() => starting), + removeTerminal: vi.fn().mockReturnValue(false), + hasRunningEntries: vi.fn(() => starting), + list: vi.fn().mockReturnValue([]), + abortAll: vi.fn(), + }; + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryB), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const predicateA = vi.mocked(Session).mock.calls[0][7] as ( + candidateRunId: string, + ) => boolean; + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { + sessionId: sessionIdB, + }); + + expect(predicateA(runId)).toBe(true); + starting = false; + expect(predicateA(runId)).toBe(false); + + mockConnectionState.resolve(); + await agentPromise; + }); + it("serializes a sibling session's retry against a history deletion", async () => { // R5-9: the mutual-exclusion claim used to be keyed `sessionId\0taskId`, // which serialized nothing that mattered — all sessions share one diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c4491e970c5..bf872c41a19 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -137,7 +137,9 @@ import { type WorkflowParams, type WorkflowToolResult, type WorkflowRunRegistry, + getWorkflowTaskMutationKey, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, listSavedWorkflows, listWorkflowSnapshots, type TurnResultRecordPayload, @@ -3384,29 +3386,6 @@ class QwenAgent implements Agent { * an orphaned live run out from under itself. Pruned as they drain. */ private readonly detachedWorkflowRegistries = new Set(); - /** - * R5-9: mutual exclusion for workflow task mutations, keyed by the task - * ALONE — never by session. All sessions in this child share one - * snapshot store, so a claim scoped to `sessionId\0taskId` serialized - * nothing that matters: a sibling session's retry of the same runId ran - * concurrently with a delete-history and neither saw the other. The - * retry passes `canStart` (`failed`, no handle), then awaits journal - * load/compile before `register()`; a delete landing in that structural - * window finds the run terminal and handle-less in every registry, - * removes the journal directory and snapshot, and answers - * `{changed: true}` — after which the retry's `register()` and its - * settlement re-persist the history the user was told was deleted. - * - * Every mutating action takes this claim (delete-history, retry, rerun, - * run-saved) and answers `{changed: false}` when it is already held. - * Once a retry has registered, `isWorkflowRunLiveOutsideSession` takes - * over and keeps deletion blocked for the life of the run. - * - * `run-saved` keys off a saved workflow's NAME rather than a runId, so - * it claims in its own namespace — a saved workflow named like a runId - * must not serialize against that run's history deletion. - */ - private readonly mutatingWorkflowTaskIds = new Set(); private activePromptCalls = new Map>(); private workspaceMcpDiscoveryConfig: Config | undefined; private workspaceMcpDiscoveryPromise: Promise | undefined; @@ -10964,71 +10943,74 @@ class QwenAgent implements Agent { if (!this.canUseWorkflowControls(config)) { return { changed: false }; } - // Task-global, NOT session-scoped — see `mutatingWorkflowTaskIds`. const mutationClaim = - action === 'run-saved' ? `saved\0${taskId}` : taskId; + action === 'run-saved' + ? getWorkflowTaskMutationKey(config, taskId, 'saved') + : getWorkflowTaskMutationKey(config, taskId); if (action === 'delete-history') { - if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { - return { changed: false }; - } - this.mutatingWorkflowTaskIds.add(mutationClaim); - try { - const changed = await session.deleteWorkflowHistory(taskId); - if (changed) { - // Every session shares the one store: drop the sibling - // registries' terminal entries too, or a retry from a - // sibling re-persists the just-deleted run. - for (const [siblingId, sibling] of this.sessions) { - if (siblingId === sessionId) continue; - sibling - .getConfig() - .getWorkflowRunRegistry() - .removeTerminal(taskId); + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + const changed = await session.deleteWorkflowHistory(taskId); + if (changed) { + // Every session shares the one store: drop the sibling + // registries' terminal entries too, or a retry from a + // sibling re-persists the just-deleted run. + for (const [siblingId, sibling] of this.sessions) { + if (siblingId === sessionId) continue; + sibling + .getConfig() + .getWorkflowRunRegistry() + .removeTerminal(taskId); + } } - } - return { changed }; - } finally { - this.mutatingWorkflowTaskIds.delete(mutationClaim); + return { changed }; + }, + ); + if (!attempt.acquired) { + return { changed: false }; } + return attempt.value; } const registry = config.getWorkflowRunRegistry(); if (action === 'run-saved') { - if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { - return { changed: false }; - } - this.mutatingWorkflowTaskIds.add(mutationClaim); - try { - const savedWorkflow = (await listSavedWorkflows(config)).find( - (entry) => entry.name === taskId, - ); - if (!savedWorkflow) return { changed: false }; - const workflowTool = config - .getToolRegistry() - .getTool(ToolNames.WORKFLOW); - if (!isSessionOwnedWorkflowTool(workflowTool)) { - throw RequestError.invalidParams( - undefined, - 'The workflow tool is unavailable; cannot run this saved workflow.', + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + const savedWorkflow = (await listSavedWorkflows(config)).find( + (entry) => entry.name === taskId, ); - } - const result = (await workflowTool - .buildSessionOwnedBackground({ - scriptPath: savedWorkflow.scriptPath, - }) - .execute(new AbortController().signal)) as WorkflowToolResult; - const startedTask = result.workflowRunId - ? registry.get(result.workflowRunId) - : undefined; - return startedTask - ? { - changed: true, - status: startedTask.status, - taskId: startedTask.runId, - } - : { changed: false }; - } finally { - this.mutatingWorkflowTaskIds.delete(mutationClaim); + if (!savedWorkflow) return { changed: false }; + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!isSessionOwnedWorkflowTool(workflowTool)) { + throw RequestError.invalidParams( + undefined, + 'The workflow tool is unavailable; cannot run this saved workflow.', + ); + } + const result = (await workflowTool + .buildSessionOwnedBackground({ + scriptPath: savedWorkflow.scriptPath, + }) + .execute(new AbortController().signal)) as WorkflowToolResult; + const startedTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + return startedTask + ? { + changed: true, + status: startedTask.status, + taskId: startedTask.runId, + } + : { changed: false }; + }, + ); + if (!attempt.acquired) { + return { changed: false }; } + return attempt.value; } const task = registry.get(taskId); if (!task) return { changed: false }; @@ -11042,50 +11024,51 @@ class QwenAgent implements Agent { if (!canStart || !task.script) { return { changed: false, status: task.status }; } - if (this.mutatingWorkflowTaskIds.has(mutationClaim)) { - return { changed: false, status: task.status }; - } - this.mutatingWorkflowTaskIds.add(mutationClaim); - try { - const workflowTool = config - .getToolRegistry() - .getTool(ToolNames.WORKFLOW); - if (!isSessionOwnedWorkflowTool(workflowTool)) { - throw RequestError.invalidParams( - undefined, - `The workflow tool is unavailable; cannot ${action} this run.`, - ); - } - const startParams: Omit = { - script: task.script, - args: task.args, - ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), - }; - const result = (await workflowTool - .buildSessionOwnedBackground(startParams) - .execute(new AbortController().signal)) as WorkflowToolResult; - if (action === 'rerun') { - const rerunTask = result.workflowRunId - ? registry.get(result.workflowRunId) - : undefined; - if (rerunTask) { - registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!isSessionOwnedWorkflowTool(workflowTool)) { + throw RequestError.invalidParams( + undefined, + `The workflow tool is unavailable; cannot ${action} this run.`, + ); } - return rerunTask - ? { - changed: true, - status: rerunTask.status, - taskId: rerunTask.runId, - } - : { changed: false, status: task.status }; - } - return { - changed: true, - status: registry.get(taskId)?.status, - }; - } finally { - this.mutatingWorkflowTaskIds.delete(mutationClaim); + const startParams: Omit = { + script: task.script, + args: task.args, + ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), + }; + const result = (await workflowTool + .buildSessionOwnedBackground(startParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + if (action === 'rerun') { + const rerunTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + if (rerunTask) { + registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + } + return rerunTask + ? { + changed: true, + status: rerunTask.status, + taskId: rerunTask.runId, + } + : { changed: false, status: task.status }; + } + return { + changed: true, + status: registry.get(taskId)?.status, + }; + }, + ); + if (!attempt.acquired) { + return { changed: false, status: task.status }; } + return attempt.value; } const changed = action === 'pause' ? registry.pause(taskId) : registry.resume(taskId); @@ -13024,6 +13007,7 @@ class QwenAgent implements Agent { registry: WorkflowRunRegistry, runId: string, ): boolean { + if (registry.isStarting?.(runId)) return true; const entry = registry.get(runId); if (entry && !isTerminalWorkflowStatus(entry.status)) return true; return registry.getHandle(runId) !== undefined; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 085eacd0ecc..c86ffac5c14 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -510,6 +510,7 @@ describe('Session', () => { resolvePendingApproval: ReturnType; get: ReturnType; getHandle: ReturnType; + isStarting: ReturnType; removeTerminal: ReturnType; list: ReturnType; abortAll: ReturnType; @@ -747,6 +748,7 @@ describe('Session', () => { resolvePendingApproval: vi.fn().mockResolvedValue(true), get: vi.fn().mockReturnValue(undefined), getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn().mockReturnValue(false), removeTerminal: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), abortAll: vi.fn(), @@ -3482,6 +3484,59 @@ describe('Session', () => { expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); }); + it('keeps deletion atomic with a direct workflow resume', async () => { + const snapshot = { + runId: 'wf_atomic', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishDeletion: ((deleted: boolean) => void) | undefined; + deleteWorkflowSnapshotSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDeletion = resolve; + }), + ); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await vi.waitFor(() => + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledOnce(), + ); + + const resumeAttempt = await core.tryWithWorkflowTaskMutation( + core.getWorkflowTaskMutationKey(mockConfig, snapshot.runId), + async () => true, + ); + expect(resumeAttempt).toEqual({ acquired: false }); + + finishDeletion?.(true); + await expect(deletion).resolves.toBe(true); + }); + + it('rejects history deletion while the workflow is starting', async () => { + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + mockWorkflowRunRegistry.isStarting.mockReturnValue(true); + + await expect(session.deleteWorkflowHistory('wf_starting')).resolves.toBe( + false, + ); + + expect(listWorkflowSnapshotsSpy).not.toHaveBeenCalled(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + it('rejects history deletion while the workflow is active', async () => { mockWorkflowRunRegistry.get.mockReturnValue({ status: 'paused' }); @@ -35934,9 +35989,18 @@ describe('Session', () => { }); it('classifies workflow notifications from the captured baseline', () => { - mockWorkflowRunRegistry.list.mockReturnValue([ - { runId: 'baseline-workflow', status: 'running' }, - ]); + const baselineWorkflow = { + runId: 'baseline-workflow', + status: 'running', + }; + let currentWorkflow: typeof baselineWorkflow | undefined = + baselineWorkflow; + mockWorkflowRunRegistry.list.mockImplementation(() => + currentWorkflow ? [currentWorkflow] : [], + ); + mockWorkflowRunRegistry.get.mockImplementation((runId: string) => + currentWorkflow?.runId === runId ? currentWorkflow : undefined, + ); rebuildSessionWithGuard(); const internals = session as unknown as { notificationProcessing: boolean; @@ -35964,6 +36028,15 @@ describe('Session', () => { status: 'completed', todoWorkChainId: 'stale-chain', }); + currentWorkflow = { + runId: 'baseline-workflow', + status: 'running', + }; + callback('retry result', '', { + runId: 'baseline-workflow', + status: 'completed', + }); + currentWorkflow = undefined; callback('new result', '', { runId: 'new-workflow', status: 'completed', @@ -35974,6 +36047,10 @@ describe('Session', () => { taskId: 'baseline-workflow', continuesTodoStopGuardWorkChain: false, }), + expect.objectContaining({ + taskId: 'baseline-workflow', + continuesTodoStopGuardWorkChain: true, + }), expect.objectContaining({ taskId: 'new-workflow', continuesTodoStopGuardWorkChain: true, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 265ed1e33c3..900896969c5 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -202,7 +202,9 @@ import { toolResultPartDiagnosticValues, getInvocationContext, runWithInvocationContext, + getWorkflowTaskMutationKey, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, MAX_RETAINED_SNAPSHOTS, toSnapshot, deleteWorkflowSnapshot, @@ -531,7 +533,7 @@ type TodoStopGuardBackgroundBaseline = { agents: Set; shells: Set; monitors: Set; - workflows: Set; + workflows: Set; wakeups: Set; }; @@ -2967,7 +2969,7 @@ export class Session implements SessionContext { .filter((item) => item.kind === 'monitor') .map((item) => item.taskId), ]), - workflows: new Set(workflows.map((task) => task.runId)), + workflows: new Set(workflows), wakeups: new Set([ ...wakeups.map((job) => job.id), ...this.cronQueue.flatMap((item) => @@ -3074,7 +3076,7 @@ export class Session implements SessionContext { if ( workflows.some( (task) => - !baseline.workflows.has(task.runId) && + !baseline.workflows.has(task) && !isTerminalWorkflowStatus(task.status), ) ) { @@ -3432,9 +3434,18 @@ export class Session implements SessionContext { } async deleteWorkflowHistory(runId: string): Promise { + const attempt = await tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(this.config, runId), + () => this.#deleteWorkflowHistoryClaimed(runId), + ); + return attempt.acquired ? attempt.value : false; + } + + async #deleteWorkflowHistoryClaimed(runId: string): Promise { const registry = this.config.getWorkflowRunRegistry(); const isDeletable = (): boolean => { if (this.isWorkflowRunLiveInSiblingSession(runId)) return false; + if (registry.isStarting?.(runId)) return false; const current = registry.get(runId); return !current || isTerminalWorkflowStatus(current.status); }; @@ -8875,6 +8886,7 @@ export class Session implements SessionContext { this.unpersistedWorkflowHistory.delete(runId); }); workflowRegistry.setCompletionCallback((displayText, modelText, meta) => { + const entry = workflowRegistry.get(meta.runId); this.#enqueueBackgroundNotification({ displayText, modelText, @@ -8882,7 +8894,7 @@ export class Session implements SessionContext { status: meta.status, kind: 'workflow', continuesTodoStopGuardWorkChain: - !this.todoStopGuardBackgroundBaseline.workflows.has(meta.runId), + !entry || !this.todoStopGuardBackgroundBaseline.workflows.has(entry), todoWorkChainId: meta.todoWorkChainId, }); }); diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index 1f139903f53..07b16a1d32a 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -8,11 +8,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getEventListeners } from 'node:events'; import type { Config } from '../../config/config.js'; import { + getWorkflowTaskMutationKey, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, WorkflowRunRegistry, type WorkflowTask, } from '../workflow-run-registry.js'; import { AgentEventEmitter } from './agent-events.js'; +import { WorkflowJournal, type JournalReplay } from './workflow-journal.js'; import { WorkflowRunner, WorkflowScriptNotLaunchedError, @@ -23,12 +26,14 @@ const { createProductionDispatchMock, journalWrites, logWorkflowRunMock, + resolveSavedWorkflowScriptMock, writeLineMock, writeWorkflowSnapshotMock, } = vi.hoisted(() => ({ createProductionDispatchMock: vi.fn(), journalWrites: [] as Array<() => void>, logWorkflowRunMock: vi.fn(), + resolveSavedWorkflowScriptMock: vi.fn(), writeLineMock: vi.fn(), writeWorkflowSnapshotMock: vi.fn().mockResolvedValue(undefined), })); @@ -56,6 +61,14 @@ vi.mock('./workflow-orchestrator.js', async (importOriginal) => { }; }); +vi.mock('./workflow-saved.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSavedWorkflowScript: resolveSavedWorkflowScriptMock, + }; +}); + function configWithRegistry(): { config: Config; registry: WorkflowRunRegistry; @@ -95,6 +108,7 @@ describe('WorkflowRunner', () => { createProductionDispatchMock.mockReset(); journalWrites.length = 0; logWorkflowRunMock.mockClear(); + resolveSavedWorkflowScriptMock.mockReset(); writeLineMock.mockReset(); writeLineMock.mockResolvedValue(undefined); writeWorkflowSnapshotMock.mockClear(); @@ -217,7 +231,50 @@ describe('WorkflowRunner', () => { it('records a journal retry as sourced from the same run', async () => { const { config, registry } = configWithRegistry(); const runId = 'wf_1234abcd'; - const handle = await WorkflowRunner.start({ + const attempt = await tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(config, runId), + () => + WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return "retried"', + args: undefined, + resumeFromRunId: runId, + runInBackground: true, + dispatch: async () => 'unused', + }), + ); + expect(attempt.acquired).toBe(true); + if (!attempt.acquired) return; + const handle = attempt.value; + + await handle.completion; + + expect(registry.get(runId)).toMatchObject({ + runId, + sourceRunId: runId, + startMode: 'retry', + }); + }); + + it('cancels a pending background resume before registration', async () => { + const { config, registry } = configWithRegistry(); + const runId = 'wf_1234abcd'; + Object.assign(config, { + storage: { + getWorkflowRunJournalPath: () => 'probe-journal.jsonl', + }, + }); + let resolveLoad: ((replay: JournalReplay) => void) | undefined; + const loadSpy = vi + .spyOn(WorkflowJournal.prototype, 'load') + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const start = WorkflowRunner.start({ config, signal: new AbortController().signal, script: 'return "retried"', @@ -227,13 +284,116 @@ describe('WorkflowRunner', () => { dispatch: async () => 'unused', }); - await handle.completion; + try { + await vi.waitFor(() => expect(registry.isStarting(runId)).toBe(true)); + expect(registry.get(runId)).toBeUndefined(); - expect(registry.get(runId)).toMatchObject({ - runId, - sourceRunId: runId, - startMode: 'retry', + registry.abortAll(); + resolveLoad?.({ results: new Map(), started: new Map() }); + + await expect(start).rejects.toThrow( + 'Background workflow start was cancelled.', + ); + expect(registry.isStarting(runId)).toBe(false); + expect(registry.get(runId)).toBeUndefined(); + } finally { + resolveLoad?.({ results: new Map(), started: new Map() }); + await start.catch(() => undefined); + loadSpy.mockRestore(); + } + }); + + it('cancels a pending background script load before registration', async () => { + const { config, registry } = configWithRegistry(); + Object.assign(config, { + storage: { + getWorkflowRunJournalPath: () => 'probe-journal.jsonl', + }, + }); + let finishLoad: + | ((saved: { name: string; script: string; scriptPath: string }) => void) + | undefined; + resolveSavedWorkflowScriptMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishLoad = resolve; + }), + ); + const start = WorkflowRunner.start({ + config, + signal: new AbortController().signal, + scriptPath: '/tmp/review.js', + args: undefined, + runInBackground: true, + dispatch: async () => 'unused', }); + const saved = { + name: 'review', + script: 'return "done"', + scriptPath: '/tmp/review.js', + }; + + try { + await vi.waitFor(() => + expect(resolveSavedWorkflowScriptMock).toHaveBeenCalledOnce(), + ); + expect(registry.hasRunningEntries()).toBe(true); + expect(registry.list()).toEqual([]); + + registry.abortAll(); + finishLoad?.(saved); + + await expect(start).rejects.toThrow( + 'Background workflow start was cancelled.', + ); + expect(registry.hasRunningEntries()).toBe(false); + expect(registry.list()).toEqual([]); + } finally { + finishLoad?.(saved); + await start.catch(() => undefined); + } + }); + + it('rejects a direct resume while history mutation owns the run', async () => { + const { config, registry } = configWithRegistry(); + const runId = 'wf_1234abcd'; + let releaseClaim: (() => void) | undefined; + let claimReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + claimReady = resolve; + }); + const claim = tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(config, runId), + async () => { + claimReady?.(); + await new Promise((resolve) => { + releaseClaim = resolve; + }); + }, + ); + await ready; + const loadSpy = vi.spyOn(WorkflowJournal.prototype, 'load'); + + try { + await expect( + WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return "retried"', + args: undefined, + resumeFromRunId: runId, + runInBackground: true, + dispatch: async () => 'unused', + }), + ).rejects.toThrow(`Workflow run ${runId} is already being modified.`); + expect(loadSpy).not.toHaveBeenCalled(); + expect(registry.isStarting(runId)).toBe(false); + expect(registry.get(runId)).toBeUndefined(); + } finally { + releaseClaim?.(); + await claim; + loadSpy.mockRestore(); + } }); it('keeps one registry-owned handle through exactly-once completion', async () => { diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index a49b4559004..e61aa94abde 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -13,7 +13,9 @@ import { createChildAbortController, } from '../../utils/abortController.js'; import { + getWorkflowTaskMutationKey, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, type WorkflowRunRegistry, type WorkflowTask, } from '../workflow-run-registry.js'; @@ -105,99 +107,126 @@ export class WorkflowRunner { static async start( options: WorkflowRunnerOptions, ): Promise { - const config = options.config; - const runInBackground = options.runInBackground === true; - const budget = WorkflowBudgetImpl.fromEnv(); - const loaded = - options.scriptPath && options.script === undefined - ? await resolveSavedWorkflowScript( - { scriptPath: options.scriptPath }, - config, - ) - : undefined; - const script = loaded?.script ?? options.script ?? ''; - const scriptPath = loaded?.scriptPath ?? options.scriptPath; - - // Refuse a script that cannot compile before anything exists to clean up. - // Everything below this line has a cost that outlives a failure: a runId is - // minted, a journal file is opened, the run is registered and shows up in - // `/workflows`, and the failure path writes a snapshot and a log entry. A - // single TypeScript annotation used to produce all of that — a phantom - // failed run for a workflow that never started. Compiling first turns it - // into a plain refusal with nothing to explain afterwards. - try { - compileWorkflowScript(script); - } catch (error) { - throw new WorkflowScriptNotLaunchedError( - describeWorkflowCompileError( - error, - script.split(/\r\n|[\n\r\u2028\u2029]/).length, - ), + if (options.resumeFromRunId) { + const attempt = await tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(options.config, options.resumeFromRunId), + () => this.startClaimed(options), ); + if (!attempt.acquired) { + throw new Error( + `Workflow run ${options.resumeFromRunId} is already being modified.`, + ); + } + return attempt.value; } + return this.startClaimed(options); + } + private static async startClaimed( + options: WorkflowRunnerOptions, + ): Promise { + const config = options.config; + const runInBackground = options.runInBackground === true; + const budget = WorkflowBudgetImpl.fromEnv(); const runId = options.resumeFromRunId ?? `wf_${randomBytes(8).toString('hex')}`; - const storage = config.storage; - const journal = storage - ? new WorkflowJournal(storage.getWorkflowRunJournalPath(runId)) - : undefined; - const resumeReplay: JournalReplay | undefined = options.resumeFromRunId - ? await journal?.load() - : undefined; - if (runInBackground && options.signal.aborted) { - throw new Error('Background workflow start was cancelled.'); - } - const callerWasAbortedBeforeStart = options.signal.aborted; const registry = config.getWorkflowRunRegistry?.(); let entry: WorkflowTask | undefined; const isCurrentEntry = (): boolean => registry === undefined || (entry !== undefined && registry.get(runId) === entry); - const controller = runInBackground - ? createAbortController() - : createChildAbortController(options.signal); - const dispatch = - options.dispatch ?? - createProductionDispatch( - config, - controller.signal, - (outputTokens) => budget.recordSpent(outputTokens), - registry - ? (emitter, dispatchId) => - isCurrentEntry() - ? registry.bridgeApprovalEvents( - runId, - emitter, - dispatchId, - entry, - ) - : () => undefined - : undefined, - ); - const orchestrator = new WorkflowOrchestrator(dispatch); + const createController = () => + runInBackground + ? createAbortController() + : createChildAbortController(options.signal); + const controller = registry + ? registry.reserveStart(runId, createController) + : createController(); + const storage = config.storage; + const journal = storage + ? new WorkflowJournal(storage.getWorkflowRunJournalPath(runId)) + : undefined; + let script: string; + let scriptPath: string | undefined; + let resumeReplay: JournalReplay | undefined; + let callerWasAbortedBeforeStart: boolean; + let orchestrator: WorkflowOrchestrator; try { - entry = registry?.register({ - runId, - toolUseId: options.toolUseId, - meta: null, - status: 'running', - startTime: Date.now(), - outputFile: '', - abortController: controller, - tokenBudgetTotal: budget.total, - script, - scriptPath, - args: options.args, - ...(options.resumeFromRunId - ? { - sourceRunId: options.resumeFromRunId, - startMode: 'retry' as const, - } - : {}), - isBackgrounded: runInBackground, - }); + const loaded = + options.scriptPath && options.script === undefined + ? await resolveSavedWorkflowScript( + { scriptPath: options.scriptPath }, + config, + ) + : undefined; + script = loaded?.script ?? options.script ?? ''; + scriptPath = loaded?.scriptPath ?? options.scriptPath; + + try { + compileWorkflowScript(script); + } catch (error) { + throw new WorkflowScriptNotLaunchedError( + describeWorkflowCompileError( + error, + script.split(/\r\n|[\n\r\u2028\u2029]/).length, + ), + ); + } + + resumeReplay = options.resumeFromRunId + ? await journal?.load() + : undefined; + if ( + runInBackground && + (controller.signal.aborted || options.signal.aborted) + ) { + throw new Error('Background workflow start was cancelled.'); + } + callerWasAbortedBeforeStart = options.signal.aborted; + const dispatch = + options.dispatch ?? + createProductionDispatch( + config, + controller.signal, + (outputTokens) => budget.recordSpent(outputTokens), + registry + ? (emitter, dispatchId) => + isCurrentEntry() + ? registry.bridgeApprovalEvents( + runId, + emitter, + dispatchId, + entry, + ) + : () => undefined + : undefined, + ); + orchestrator = new WorkflowOrchestrator(dispatch); + entry = registry?.register( + { + runId, + toolUseId: options.toolUseId, + meta: null, + status: 'running', + startTime: Date.now(), + outputFile: '', + abortController: controller, + tokenBudgetTotal: budget.total, + script, + scriptPath, + args: options.args, + ...(options.resumeFromRunId + ? { + sourceRunId: options.resumeFromRunId, + startMode: 'retry' as const, + } + : {}), + isBackgrounded: runInBackground, + }, + controller, + ); } catch (error) { + registry?.releaseStart(runId, controller); controller.abort(); throw error; } diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index 7bc2be538eb..e654a11e0de 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -20,7 +20,9 @@ import { MAX_RETAINED_TERMINAL_WORKFLOWS, isActiveWorkflowStatus, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, type WorkflowApprovalRequestCallback, + type WorkflowTaskMutationAttempt, type WorkflowTaskRegistration, type WorkflowStatus, } from './workflow-run-registry.js'; @@ -75,6 +77,47 @@ function approvalEvent( } describe('WorkflowRunRegistry', () => { + it('does not inherit a stale workflow task mutation claim', async () => { + const mutationKey = 'scope\0run\0wf_stale'; + let releaseStale: () => void = () => {}; + let releaseCompeting: () => void = () => {}; + let signalCompeting: () => void = () => {}; + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + const competingStarted = new Promise((resolve) => { + signalCompeting = resolve; + }); + let staleAttempt: Promise> | undefined; + + const original = await tryWithWorkflowTaskMutation( + mutationKey, + async () => { + staleAttempt = staleGate.then(() => + tryWithWorkflowTaskMutation(mutationKey, async () => 'stale'), + ); + return 'original'; + }, + ); + const competing = tryWithWorkflowTaskMutation(mutationKey, async () => { + signalCompeting(); + await new Promise((resolve) => { + releaseCompeting = resolve; + }); + return 'competing'; + }); + + await competingStarted; + releaseStale(); + const staleResult = await staleAttempt; + releaseCompeting(); + const competingResult = await competing; + + expect(original).toEqual({ acquired: true, value: 'original' }); + expect(staleResult).toEqual({ acquired: false }); + expect(competingResult).toEqual({ acquired: true, value: 'competing' }); + }); + it('records rerun lineage and notifies status observers', () => { const r = new WorkflowRunRegistry(); const onStatusChange = vi.fn(); @@ -1075,6 +1118,39 @@ describe('WorkflowRunRegistry', () => { expect(r.register(reg(runId)).status).toBe('running'); }); + it('reserves a run id while a workflow is starting', () => { + const r = new WorkflowRunRegistry(); + const runId = 'wf_starting'; + const owner = new AbortController(); + const competing = new AbortController(); + + r.reserveStart(runId, () => owner); + + expect(r.isStarting(runId)).toBe(true); + expect(r.hasRunningEntries()).toBe(true); + expect(() => r.reserveStart(runId, () => competing)).toThrow( + /already active/, + ); + expect(() => r.register(reg(runId))).toThrow(/already active/); + + const entry = r.register(reg(runId), owner); + expect(entry.runId).toBe(runId); + expect(r.isStarting(runId)).toBe(false); + }); + + it('aborts a workflow that has not registered yet', () => { + const r = new WorkflowRunRegistry(); + const controller = new AbortController(); + + r.reserveStart('wf_starting', () => controller); + r.abortAll(); + + expect(controller.signal.aborted).toBe(true); + expect(r.isStarting('wf_starting')).toBe(true); + r.releaseStart('wf_starting', controller); + expect(r.hasRunningEntries()).toBe(false); + }); + it('register synthesizes description from meta.name when omitted', () => { const r = new WorkflowRunRegistry(); const entry = r.register( @@ -1703,6 +1779,30 @@ describe('WorkflowRunRegistry', () => { expect(ids).not.toContain('wf_0'); }); + it('does not evict a terminal entry until its handle is released', () => { + const r = new WorkflowRunRegistry(); + const held = r.register(reg('wf_held')); + const handle = { + runId: held.runId, + abort: vi.fn(), + } as unknown as WorkflowRunHandle; + r.attachHandle(handle); + r.complete(held.runId, null, 1_000); + + for (let i = 0; i < MAX_RETAINED_TERMINAL_WORKFLOWS; i++) { + r.register(reg(`wf_new_${i}`)); + r.complete(`wf_new_${i}`, null, 2_000 + i); + } + + expect(r.get(held.runId)).toBe(held); + expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS + 1); + + r.releaseHandle(held.runId, handle); + + expect(r.get(held.runId)).toBeUndefined(); + expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS); + }); + it('active entries are never evicted', () => { const r = new WorkflowRunRegistry(); r.register(reg('runner')); diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 7610c71fdb9..75c4bb2b214 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -22,6 +22,8 @@ * consumer replacing the other. */ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Config } from '../config/config.js'; import type { TaskBase, TaskRegistration } from './tasks/types.js'; import type { WorkflowMeta } from './runtime/workflow-sandbox.js'; import type { WorkflowRunHandle } from './runtime/workflow-runner.js'; @@ -44,6 +46,69 @@ import type { WorkflowDispatchState } from './runtime/workflow-dispatch-schedule const debugLogger = createDebugLogger('WORKFLOW_REGISTRY'); +const mutatingWorkflowTasks = new Map(); +const workflowTaskMutationContext = new AsyncLocalStorage< + ReadonlyMap +>(); +const inMemoryMutationScopeIds = new WeakMap(); +let nextInMemoryMutationScopeId = 1; + +export function getWorkflowTaskMutationKey( + config: Config, + taskId: string, + namespace = 'run', +): string { + const storage = config.storage as + | { getWorkflowRunsDir?: () => string } + | undefined; + const workflowRunsDir = storage?.getWorkflowRunsDir?.(); + if (workflowRunsDir) { + return `${workflowRunsDir}\0${namespace}\0${taskId}`; + } + + const owner = storage ?? config.getWorkflowRunRegistry?.() ?? config; + let scopeId = inMemoryMutationScopeIds.get(owner); + if (scopeId === undefined) { + scopeId = nextInMemoryMutationScopeId++; + inMemoryMutationScopeIds.set(owner, scopeId); + } + return `memory:${scopeId}\0${namespace}\0${taskId}`; +} + +export type WorkflowTaskMutationAttempt = + | { acquired: true; value: T } + | { acquired: false }; + +export async function tryWithWorkflowTaskMutation( + mutationKey: string, + operation: () => Promise, +): Promise> { + const inherited = workflowTaskMutationContext.getStore(); + const inheritedOwner = inherited?.get(mutationKey); + if ( + inheritedOwner !== undefined && + mutatingWorkflowTasks.get(mutationKey) === inheritedOwner + ) { + return { acquired: true, value: await operation() }; + } + if (mutatingWorkflowTasks.has(mutationKey)) return { acquired: false }; + + const owner = Symbol(mutationKey); + mutatingWorkflowTasks.set(mutationKey, owner); + const context = new Map(inherited); + context.set(mutationKey, owner); + try { + return { + acquired: true, + value: await workflowTaskMutationContext.run(context, operation), + }; + } finally { + if (mutatingWorkflowTasks.get(mutationKey) === owner) { + mutatingWorkflowTasks.delete(mutationKey); + } + } +} + /** * Cap on terminal entries retained for dialog history. Picked smaller * than `MAX_RETAINED_TERMINAL_AGENTS` (32) because workflow rows carry @@ -380,6 +445,7 @@ interface WorkflowApprovalRuntime { export class WorkflowRunRegistry { private readonly entries = new Map(); private readonly handles = new Map(); + private readonly starting = new Map(); private registerCallback: WorkflowRunRegisterCallback | undefined; private statusChangeCallback: WorkflowRunStatusChangeCallback | undefined; @@ -531,14 +597,48 @@ export class WorkflowRunRegistry { * callers can keep using their local reference post-register and * observers see updates without an extra `get()`. */ - register(registration: WorkflowTaskRegistration): WorkflowTask { + reserveStart( + runId: string, + createController: () => AbortController, + ): AbortController { + const existing = this.entries.get(runId); + if ( + (existing && isActiveWorkflowStatus(existing.status)) || + this.handles.has(runId) || + this.starting.has(runId) + ) { + throw new Error(`Workflow run ${runId} is already active.`); + } + const controller = createController(); + this.starting.set(runId, controller); + return controller; + } + + releaseStart(runId: string, controller: AbortController): void { + if (this.starting.get(runId) === controller) this.starting.delete(runId); + } + + isStarting(runId: string): boolean { + return this.starting.has(runId); + } + + register( + registration: WorkflowTaskRegistration, + startController?: AbortController, + ): WorkflowTask { const existing = this.entries.get(registration.runId); + const reservedController = this.starting.get(registration.runId); if ( (existing && isActiveWorkflowStatus(existing.status)) || - this.handles.has(registration.runId) + this.handles.has(registration.runId) || + (reservedController !== undefined && + reservedController !== startController) ) { throw new Error(`Workflow run ${registration.runId} is already active.`); } + if (reservedController === startController) { + this.starting.delete(registration.runId); + } const entry = registration as WorkflowTask; entry.id = registration.runId; entry.kind = 'workflow'; @@ -623,7 +723,9 @@ export class WorkflowRunRegistry { } releaseHandle(runId: string, handle: WorkflowRunHandle): void { - if (this.handles.get(runId) === handle) this.handles.delete(runId); + if (this.handles.get(runId) !== handle) return; + this.handles.delete(runId); + this.evictTerminal(); } bridgeApprovalEvents( @@ -1250,6 +1352,7 @@ export class WorkflowRunRegistry { * `reset()` so they settle terminal instead of leaking. */ hasRunningEntries(): boolean { + if (this.starting.size > 0) return true; for (const entry of this.entries.values()) { if (entry.status === 'running' || entry.status === 'pausing') { return true; @@ -1301,6 +1404,9 @@ export class WorkflowRunRegistry { abortAll(): void { const endTime = Date.now(); let lastCancelled: WorkflowTask | undefined; + for (const controller of this.starting.values()) { + controller.abort(); + } for (const entry of Array.from(this.entries.values())) { if (!isActiveWorkflowStatus(entry.status)) continue; this.rejectPendingApprovals(entry.runId, undefined, endTime); @@ -1386,8 +1492,10 @@ export class WorkflowRunRegistry { * (by `endTime`) are evicted first. */ private evictTerminal(): void { - const terminal = this.list().filter((e) => - isTerminalWorkflowStatus(e.status), + const terminal = this.list().filter( + (entry) => + isTerminalWorkflowStatus(entry.status) && + !this.handles.has(entry.runId), ); if (terminal.length <= MAX_RETAINED_TERMINAL_WORKFLOWS) return; terminal.sort((a, b) => (a.endTime ?? 0) - (b.endTime ?? 0)); From 6c7949002c52c1d3b172f0a779967e4d6ecb6fce Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 00:47:51 +0800 Subject: [PATCH 12/23] fix(workflows): let a starting run be cancelled, and stop two slow leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the behavioural items the review deferred across rounds 8-9; the test-coverage-only entries stay deferred. - Cancel during the start window (R9 acpAgent.ts:10884 + workflow-runner.ts:181). Between `reserveStart` and `register` the runner loads the script and replays the journal — seconds, for a resume of a large one — and the registry has no entry yet. `sessionTaskCancel` answered `not_found` for a run the client could see starting, and `registry.cancel` could not reach the reserved controller either. New `cancelStarting` aborts it (the reservation stays the runner's to release, as after `abortAll`), and the cancel handler routes there when the liveness gate would say "starting". Doing that exposed the second half: the runner threw a bare `Error` for an abort during start, and the tool's catch only recognised the CALLER's signal — a registry-side abort surfaced as an unexplained failure. It is now a typed `WorkflowStartCancelledError`, mapped to the same "cancelled before it could start" result. - `detachedWorkflowRegistries` (R8 acpAgent.ts:3386) was pruned only inside the delete-history liveness check. A daemon that closes sessions mid-run and never deletes history retained every registry for its lifetime. Prune on session close as well. - Refresh/delete race (R9 Session.ts:3428). `refreshWorkflowHistory` reads the directory and merges without a claim; a delete landing between the read and the merge was overwritten by the stale listing and the run reappeared until the next refresh. Deletions are now sequenced, and a refresh drops any run deleted after its read began — keyed by runId and compared against the refresh's own mark, so a later retry that reuses the id is not suppressed. - The "Register a new run" JSDoc sat on `reserveStart` (R9 registry:594). Mutation-verified, all four at once against the full suites: disabling the starting-window branch, the prune-on-close, the deletion filter, and the typed-error mapping reddens exactly the four new tests and nothing else. Claude-Session: https://claude.ai/code/session_01VXsC4f71S6U6YkW82NRw7m --- .../cli/src/acp-integration/acpAgent.test.ts | 119 ++++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 16 +++ .../acp-integration/session/Session.test.ts | 63 ++++++++++ .../src/acp-integration/session/Session.ts | 17 +++ .../agents/runtime/workflow-runner.test.ts | 5 + .../src/agents/runtime/workflow-runner.ts | 17 ++- .../src/agents/workflow-run-registry.test.ts | 20 +++ .../core/src/agents/workflow-run-registry.ts | 33 ++++- .../core/src/tools/workflow/workflow.test.ts | 42 +++++++ packages/core/src/tools/workflow/workflow.ts | 11 +- 10 files changed, 337 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c386b646d5e..6bcf28bc302 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -10957,6 +10957,63 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('cancels a workflow that has been reserved but not yet registered', async () => { + // Between `reserveStart` and `register` the runner is loading the + // script or replaying the journal; the registry has no entry yet. + // The liveness gate already counts that window as live — cancel + // answered "not_found" for it, so a client watching the run start + // could not stop it until it registered. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const cancel = vi.fn(); + const cancelStarting = vi.fn().mockReturnValue(true); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn((runId: string) => runId === 'wf-starting'), + cancelStarting, + cancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-starting', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: true, status: 'cancelled' }); + expect(cancelStarting).toHaveBeenCalledWith('wf-starting'); + expect(cancel).not.toHaveBeenCalled(); + + // A run that is neither registered nor starting is still not found. + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-unknown', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: false, reason: 'not_found' }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it.each([ ['workflows are disabled', false, false, false, true], ['bare mode is active', true, true, false, true], @@ -11652,6 +11709,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('prunes drained registries of closed sessions when another session closes', async () => { + // Retained registries were pruned only inside the delete-history + // liveness check. A daemon that closes sessions mid-run but never + // deletes history kept every one of them for its whole lifetime. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const sessionIdC = 'cccccccc-3333-3333-3333-333333333333'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const drainingRegistry = () => ({ + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), + hasRunningEntries: vi.fn().mockReturnValue(true), + list: vi.fn().mockReturnValue([]), + abortAll: vi.fn(), + }); + const openAndClose = async (sessionId: string, registry: unknown) => { + vi.mocked(loadCliConfig).mockResolvedValue({ + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionId), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + } as unknown as Config); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { + sessionId, + }); + }; + const retained = ( + agent as unknown as { detachedWorkflowRegistries: Set } + ).detachedWorkflowRegistries; + + const registryB = drainingRegistry(); + await openAndClose(sessionIdB, registryB); + expect([...retained]).toEqual([registryB]); + + // B's run settles. Nothing observes that directly — the next close + // is where the retention set gets a chance to let go of it. + registryB.hasRunningEntries.mockReturnValue(false); + const registryC = drainingRegistry(); + await openAndClose(sessionIdC, registryC); + expect([...retained]).toEqual([registryC]); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('retains a closed session registry while a workflow is starting', async () => { const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index bf872c41a19..eba2418bff2 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4030,6 +4030,10 @@ class QwenAgent implements Agent { // Retention is bookkeeping, not cleanup: a Config that cannot answer // must not turn a successful close into a shutdown failure. try { + // Prune here as well as on the delete-history path: a daemon that + // closes sessions mid-run but never deletes history would otherwise + // retain every one of their registries for its whole lifetime. + this.pruneDrainedWorkflowRegistries(); const registry = session.getConfig().getWorkflowRunRegistry?.(); if (registry && QwenAgent.isWorkflowRegistryDraining(registry)) { this.detachedWorkflowRegistries.add(registry); @@ -10883,6 +10887,18 @@ class QwenAgent implements Agent { } const registry = config.getWorkflowRunRegistry(); const task = registry.get(taskId); + // A reserved-but-unregistered run has no entry yet: the runner + // is still loading its script or replaying its journal. The + // liveness gate already treats that window as live; cancel + // must too, or the client is told "not_found" about a run it + // can see starting, and cannot stop it until it registers. + if (!task && registry.isStarting?.(taskId)) { + registry.cancelStarting(taskId); + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=starting`, + ); + return { cancelled: true, status: 'cancelled' }; + } if ( !task || (task.status !== 'running' && diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index c86ffac5c14..ca46159f193 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -2851,6 +2851,69 @@ describe('Session', () => { ]); }); + it('does not republish a run deleted while a refresh was still reading the disk', async () => { + // Refresh reads the directory and then merges without a claim, while + // deletion holds one: a delete that lands between the read and the + // merge was overwritten by the stale listing, and the run came back + // until the next refresh. + session.dispose(); + const snapshot = { + runId: 'wf_stale', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + session = new Session( + 'racing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + let finishStaleRead!: (snapshots: Array) => void; + listWorkflowSnapshotsSpy.mockImplementationOnce( + () => + new Promise>((resolve) => { + finishStaleRead = resolve; + }), + ); + const staleRefresh = session.refreshWorkflowHistory(); + await vi.waitFor(() => expect(finishStaleRead).toBeDefined()); + + // The delete's own refresh sees the file, then removes it. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + true, + ); + expect(session.getWorkflowHistory()).toEqual([]); + + // The read that began before the delete now completes with the + // pre-delete listing. + finishStaleRead([snapshot]); + await staleRefresh; + + expect(session.getWorkflowHistory()).toEqual([]); + + // A later refresh that genuinely finds the run again (a retry reuses + // the runId) must not be suppressed by the old deletion. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await session.refreshWorkflowHistory(); + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_stale' }), + ]); + }); + it('drops persisted workflow history deleted by another session', async () => { session.dispose(); const snapshot = { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 900896969c5..4b4fbd9d305 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2029,6 +2029,18 @@ export class Session implements SessionContext { string, WorkflowSnapshot >(); + /** + * Deletion order, so a refresh can tell which runs were deleted AFTER + * its disk read began. `refreshWorkflowHistory` reads the directory + * and then merges without holding a claim, while deletion holds one — + * a delete that lands between the read and the merge would otherwise + * be overwritten by the stale listing and the run would reappear + * until the next refresh. Keyed by runId so a later re-run of the same + * id (a retry reuses it) is not suppressed: its sequence predates that + * refresh's mark. + */ + private workflowDeletionSeq = 0; + private readonly workflowDeletionSeqByRunId = new Map(); #shellStatusChangeCallback: (() => void) | undefined; private readonly workflowApprovalAbortController = new AbortController(); private activeTodoPlanRevision?: { @@ -3403,10 +3415,14 @@ export class Session implements SessionContext { } async refreshWorkflowHistory(): Promise { + const deletionMark = this.workflowDeletionSeq; const persisted = await listWorkflowSnapshots(this.config); const byRunId = new Map( persisted.map((snapshot) => [snapshot.runId, snapshot]), ); + for (const [runId, seq] of this.workflowDeletionSeqByRunId) { + if (seq > deletionMark) byRunId.delete(runId); + } for (const [runId, snapshot] of this.unpersistedWorkflowHistory) { const stored = byRunId.get(runId); if (stored === undefined) { @@ -3476,6 +3492,7 @@ export class Session implements SessionContext { return false; } if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; + this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq); registry.removeTerminal(runId); this.unpersistedWorkflowHistory.delete(runId); this.mergedWorkflowRunIds.delete(runId); diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index 07b16a1d32a..ca3ffd8733f 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -19,6 +19,7 @@ import { WorkflowJournal, type JournalReplay } from './workflow-journal.js'; import { WorkflowRunner, WorkflowScriptNotLaunchedError, + WorkflowStartCancelledError, } from './workflow-runner.js'; import { compileWorkflowScript } from './workflow-sandbox.js'; @@ -346,6 +347,10 @@ describe('WorkflowRunner', () => { await expect(start).rejects.toThrow( 'Background workflow start was cancelled.', ); + // Typed, not a bare Error: the tool maps this to its "cancelled + // before it could start" result even when the caller's own signal + // is still live. + await expect(start).rejects.toBeInstanceOf(WorkflowStartCancelledError); expect(registry.hasRunningEntries()).toBe(false); expect(registry.list()).toEqual([]); } finally { diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index e61aa94abde..27502688f48 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -103,6 +103,21 @@ export class WorkflowScriptNotLaunchedError extends Error { } } +/** + * A background start that was cancelled before it registered — by the + * caller's signal, or by `WorkflowRunRegistry.cancelStarting` aborting the + * run's own controller while the caller's signal stayed live. The second + * source is why this is a class and not a bare `Error`: the tool cannot + * tell it from a genuine start failure by looking at the caller's signal, + * and would otherwise surface "cancelled" as an unexplained error. + */ +export class WorkflowStartCancelledError extends Error { + constructor() { + super('Background workflow start was cancelled.'); + this.name = 'WorkflowStartCancelledError'; + } +} + export class WorkflowRunner { static async start( options: WorkflowRunnerOptions, @@ -180,7 +195,7 @@ export class WorkflowRunner { runInBackground && (controller.signal.aborted || options.signal.aborted) ) { - throw new Error('Background workflow start was cancelled.'); + throw new WorkflowStartCancelledError(); } callerWasAbortedBeforeStart = options.signal.aborted; const dispatch = diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index e654a11e0de..514c8c00216 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -1151,6 +1151,26 @@ describe('WorkflowRunRegistry', () => { expect(r.hasRunningEntries()).toBe(false); }); + it('cancelStarting aborts a reserved start and leaves the release to the runner', () => { + const r = new WorkflowRunRegistry(); + const controller = new AbortController(); + + expect(r.cancelStarting('wf_absent')).toBe(false); + + r.reserveStart('wf_starting', () => controller); + expect(r.cancelStarting('wf_starting')).toBe(true); + expect(controller.signal.aborted).toBe(true); + // Same contract as abortAll: the reservation stays until the runner's + // start-failure path releases it, so a competing start cannot slip in + // between the abort and that release. + expect(r.isStarting('wf_starting')).toBe(true); + expect(() => + r.reserveStart('wf_starting', () => new AbortController()), + ).toThrow(/already active/); + r.releaseStart('wf_starting', controller); + expect(r.hasRunningEntries()).toBe(false); + }); + it('register synthesizes description from meta.name when omitted', () => { const r = new WorkflowRunRegistry(); const entry = r.register( diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 75c4bb2b214..302f31b4004 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -592,10 +592,11 @@ export class WorkflowRunRegistry { } /** - * Register a new run. Mutates the registration in place to graduate - * it to a `WorkflowTask` (sets `id`, `kind`, derived counters), so - * callers can keep using their local reference post-register and - * observers see updates without an extra `get()`. + * Hold a run id for a workflow whose start is still in flight — the + * runner reserves before it loads the script and replays the journal, + * and only `register`s once both succeeded. The reservation is what + * makes the id visible to liveness and cancel checks during that + * window; the returned controller is the run's own. */ reserveStart( runId: string, @@ -622,6 +623,30 @@ export class WorkflowRunRegistry { return this.starting.has(runId); } + /** + * Cancel a run that has been reserved but not yet registered. Aborts + * the reserved controller only — the reservation itself is the + * runner's to release, in its start-failure path, exactly as after + * `abortAll`. Returns `false` when nothing is starting under `runId`, + * so a caller can fall through to the registered-entry route. + */ + cancelStarting(runId: string): boolean { + const controller = this.starting.get(runId); + if (!controller) return false; + try { + controller.abort(); + } catch (error) { + debugLogger.error('Failed to abort a starting workflow:', error); + } + return true; + } + + /** + * Register a new run. Mutates the registration in place to graduate + * it to a `WorkflowTask` (sets `id`, `kind`, derived counters), so + * callers can keep using their local reference post-register and + * observers see updates without an extra `get()`. + */ register( registration: WorkflowTaskRegistration, startController?: AbortController, diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index e5fb5b04539..ab56abbe00f 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -732,6 +732,48 @@ await agent('scan package.json') expect(dispatch).not.toHaveBeenCalled(); }); + it('reports a registry-side cancel during background preflight as cancelled, not failed', async () => { + // `sessionTaskCancel` on a run that is still loading aborts the run's + // own controller via `cancelStarting`; the caller's signal stays live, + // so the catch cannot recognise the outcome from `signal.aborted`. + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { + storage: new Storage(path.join(os.tmpdir(), 'workflow-preflight-test')), + isInteractive: () => true, + getWorkflowRunRegistry: () => registry, + } as unknown as Config; + const caller = new AbortController(); + const dispatch = vi.fn(async () => 'unused'); + const load = vi + .spyOn(WorkflowJournal.prototype, 'load') + .mockImplementation(async () => { + expect(registry.cancelStarting('wf_1234abcd')).toBe(true); + return { results: new Map(), started: new Map() }; + }); + + try { + const result = await new WorkflowTool(config, { dispatch }) + .build({ + script: 'return 1', + resumeFromRunId: 'wf_1234abcd', + run_in_background: true, + }) + .execute(caller.signal); + + expect(caller.signal.aborted).toBe(false); + expect(result).toEqual({ + llmContent: 'Workflow was cancelled before it could start.', + returnDisplay: 'Workflow cancelled.', + }); + expect(registry.list()).toHaveLength(0); + expect(registry.isStarting('wf_1234abcd')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + }); + it('does not register when cancellation arrives during background preflight', async () => { const registry = new WorkflowRunRegistry(); registry.setCompletionCallback(vi.fn()); diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index 91d1d6d174b..0795e7dfc4b 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -61,6 +61,7 @@ import { import { WorkflowRunner, WorkflowScriptNotLaunchedError, + WorkflowStartCancelledError, type WorkflowRunHandle, } from '../../agents/runtime/workflow-runner.js'; import { isSymlinkedRoot } from '../../agents/runtime/workflow-saved.js'; @@ -381,7 +382,15 @@ class WorkflowToolInvocation extends BaseToolInvocation< : undefined, }); } catch (error) { - if (runInBackground && signal.aborted) { + // Two cancel sources reach a background start: the caller's own + // signal, and a registry-side cancel (`cancelStarting`) that aborts + // the run's controller while the caller's signal stays live. The + // runner reports the latter with a typed error; both are the same + // outcome to the model. + if ( + runInBackground && + (signal.aborted || error instanceof WorkflowStartCancelledError) + ) { return backgroundStartCancelledResult(); } // A script that never compiled has no run behind it, so reporting it as From f11960739a0df4c753dac9d2a7fda5341f0ff04f Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 10:34:16 +0800 Subject: [PATCH 13/23] fix(core): close the round-5 review findings on workflow task controls - Key the starting-window cancel on a live reservation rather than on the absence of an entry: a retry reuses its runId, so its terminal entry shadowed the reservation and cancel answered `not_running` about a run that was actively starting. - Answer `changed: false` from retry when `execute()` reports a start that never registered (no `workflowRunId`), mirroring rerun and run-saved. - Classify a registry-side abort of the reserved controller as a cancel in foreground starts too, not only background ones; the tool maps `WorkflowStartCancelledError` in either mode. - Report reserved-but-unregistered runs as workflow active-work holds (`WorkflowRunRegistry.listStartingRunIds`), so a daemon conditional close cannot dispose the session under a start it just accepted. - Propagate a successful history deletion into every sibling session's deletion marker and cached history, symmetric to the `removeTerminal` sweep, so a sibling refresh that had already read the directory cannot republish the deleted run. Claude-Session: https://claude.ai/code/session_018dYE4LwSMeMPFchXk5UBdM --- .../cli/src/acp-integration/acpAgent.test.ts | 126 +++++++++++++++++- packages/cli/src/acp-integration/acpAgent.ts | 31 ++++- .../acp-integration/session/Session.test.ts | 107 +++++++++++++++ .../src/acp-integration/session/Session.ts | 39 +++++- .../agents/runtime/workflow-runner.test.ts | 8 +- .../src/agents/runtime/workflow-runner.ts | 32 +++-- .../core/src/agents/workflow-run-registry.ts | 11 ++ .../core/src/tools/workflow/workflow.test.ts | 44 ++++++ packages/core/src/tools/workflow/workflow.ts | 22 +-- 9 files changed, 383 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 6bcf28bc302..5bce2bce244 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -2100,6 +2100,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { releaseTodoStopGuardQueuedPromptWait: ReturnType; refreshWorkflowHistory: ReturnType; deleteWorkflowHistory: ReturnType; + noteExternalWorkflowDeletion: ReturnType; isIdle: ReturnType; isTurnIdle: ReturnType; getCreatedAt: ReturnType; @@ -4351,6 +4352,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockListWorkflowSnapshots(createdConfig), ), deleteWorkflowHistory: vi.fn().mockResolvedValue(false), + noteExternalWorkflowDeletion: vi.fn(), sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), replayHistory: vi.fn().mockResolvedValue(undefined), installRewriter: vi.fn(), @@ -11014,6 +11016,57 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('cancels a retry whose starting window is shadowed by its terminal entry', async () => { + // A retry reuses its runId, so the old terminal entry stays in the + // registry for the whole starting window (`reserveStart` writes only + // the starting map). Gating the starting branch on `!task` made it + // unreachable for retries: the status guard answered "not_running" + // about a run that was actively starting, and the retry went on to + // register and dispatch agents after the user was told nothing ran. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const cancel = vi.fn(); + const cancelStarting = vi.fn().mockReturnValue(true); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn((runId: string) => + runId === 'wf-retrying' ? { runId, status: 'failed' } : undefined, + ), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn((runId: string) => runId === 'wf-retrying'), + cancelStarting, + cancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-retrying', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: true, status: 'cancelled' }); + expect(cancelStarting).toHaveBeenCalledWith('wf-retrying'); + expect(cancel).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it.each([ ['workflows are disabled', false, false, false, true], ['bare mode is active', true, true, false, true], @@ -11282,7 +11335,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }; const execute = vi.fn().mockImplementation(async () => { task.status = 'running'; - return { llmContent: 'started' }; + return { llmContent: 'started', workflowRunId: task.runId }; }); const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); Object.assign(innerConfig, { @@ -11326,6 +11379,67 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('reports a retry cancelled in its starting window as unchanged', async () => { + // `execute()` reports a start that never registered — a session + // dispose or `cancelStarting` landing while the journal was still + // loading — by omitting `workflowRunId`. Answering `changed: true` + // for that shape told the client a run existed that nothing would + // ever progress. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as const, + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockResolvedValue({ + llmContent: 'Workflow was cancelled before it could start.', + returnDisplay: 'Workflow cancelled.', + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_1234abcd', + action: 'retry', + }), + ).resolves.toEqual({ changed: false, status: 'failed' }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it.each(['retry', 'rerun'] as const)( 'admits only one overlapping %s request for a workflow task', async (action) => { @@ -11366,7 +11480,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { else rerunStarted = true; return { llmContent: 'started', - ...(action === 'rerun' ? { workflowRunId: rerunTask.runId } : {}), + workflowRunId: action === 'rerun' ? rerunTask.runId : task.runId, }; }); const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); @@ -11881,7 +11995,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }); const execute = vi.fn(async () => { await retryGate; - return { llmContent: 'started' }; + return { llmContent: 'started', workflowRunId: runId }; }); const innerConfigB = { ...makeInnerConfig(), @@ -11971,6 +12085,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }) as AgentLike; await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const sessionMockA = lastSessionMock!; // Every session in this child shares the workflow store but keeps a // private registry; the second session must see the first's runs. @@ -12025,6 +12140,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 'wf_shared', ); expect(registryA.removeTerminal).toHaveBeenCalledWith('wf_shared'); + // ...and the deletion is marked in the sibling's history, so a refresh + // of A's that had already read the directory cannot republish it. + expect(sessionMockA.noteExternalWorkflowDeletion).toHaveBeenCalledWith( + 'wf_shared', + ); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index eba2418bff2..042142f56c3 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -10892,7 +10892,14 @@ class QwenAgent implements Agent { // liveness gate already treats that window as live; cancel // must too, or the client is told "not_found" about a run it // can see starting, and cannot stop it until it registers. - if (!task && registry.isStarting?.(taskId)) { + // A retry reuses its runId, so during ITS starting window the + // old terminal entry is still there — a live reservation + // shadowed by a terminal entry is the same starting run, not + // a "not_running" one. + if ( + (!task || isTerminalWorkflowStatus(task.status)) && + registry.isStarting?.(taskId) + ) { registry.cancelStarting(taskId); debugLogger.info( `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=starting`, @@ -10971,13 +10978,17 @@ class QwenAgent implements Agent { if (changed) { // Every session shares the one store: drop the sibling // registries' terminal entries too, or a retry from a - // sibling re-persists the just-deleted run. + // sibling re-persists the just-deleted run — and mark the + // deletion in each sibling's history, or a sibling refresh + // that began reading the directory before this delete + // landed merges the stale listing and republishes the run. for (const [siblingId, sibling] of this.sessions) { if (siblingId === sessionId) continue; sibling .getConfig() .getWorkflowRunRegistry() .removeTerminal(taskId); + sibling.noteExternalWorkflowDeletion(taskId); } } return { changed }; @@ -11075,10 +11086,18 @@ class QwenAgent implements Agent { } : { changed: false, status: task.status }; } - return { - changed: true, - status: registry.get(taskId)?.status, - }; + // `execute()` reports a start that never registered — a + // cancel landing in the retry's starting window, whether from + // `cancelStarting` or a session dispose — by omitting + // `workflowRunId`, the same shape the rerun and run-saved + // branches gate on. Answering `changed: true` there tells the + // client a run exists that nothing will ever progress. + return result.workflowRunId + ? { + changed: true, + status: registry.get(result.workflowRunId)?.status, + } + : { changed: false, status: task.status }; }, ); if (!attempt.acquired) { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ca46159f193..661e8eff707 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -511,6 +511,7 @@ describe('Session', () => { get: ReturnType; getHandle: ReturnType; isStarting: ReturnType; + listStartingRunIds: ReturnType; removeTerminal: ReturnType; list: ReturnType; abortAll: ReturnType; @@ -749,6 +750,7 @@ describe('Session', () => { get: vi.fn().mockReturnValue(undefined), getHandle: vi.fn().mockReturnValue(undefined), isStarting: vi.fn().mockReturnValue(false), + listStartingRunIds: vi.fn().mockReturnValue([]), removeTerminal: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), abortAll: vi.fn(), @@ -1197,6 +1199,31 @@ describe('Session', () => { session.dispose(); }); + it('holds a workflow run that is reserved but not yet registered', () => { + // Between `reserveStart` and `register` the run has no `list()` + // entry, yet the registry's hasRunningEntries() and the liveness + // gates already count it as live. A daemon conditional close that + // read no hold here disposed the session and aborted the start + // under the client that just asked for it. + mockWorkflowRunRegistry.listStartingRunIds.mockReturnValue([ + 'wf-starting', + ]); + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'wf-paused', status: 'paused' }, + ]); + createReportingSession(); + + expect(holdIds('workflow')).toEqual(['wf-starting']); + expect(session.isIdle()).toBe(false); + + // Registration takes over with the entry's own running hold; a + // failed or cancelled start drops the reservation. + mockWorkflowRunRegistry.listStartingRunIds.mockReturnValue([]); + expect(session.collectActiveWorkHolds()).toEqual([]); + expect(session.isIdle()).toBe(true); + session.dispose(); + }); + it('tracks shell status changes and retracts only its callback', () => { createReportingSession(); const statusChanged = @@ -2914,6 +2941,86 @@ describe('Session', () => { ]); }); + it('does not republish a run a sibling session deleted while this refresh was still reading the disk', async () => { + // The deletion-sequence marker is per-Session, but the store and the + // delete entrance are process-wide: session A's delete landing while + // session B's refresh had already read the directory left nothing in + // B to filter the stale listing, and B republished the run A's + // client was just told was gone. The delete handler now marks the + // deletion in every sibling, under its claim. + session.dispose(); + const snapshot = { + runId: 'wf_cross', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deleting = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + session = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + try { + let finishStaleRead!: (snapshots: Array) => void; + listWorkflowSnapshotsSpy.mockImplementationOnce( + () => + new Promise>((resolve) => { + finishStaleRead = resolve; + }), + ); + const staleRefresh = session.refreshWorkflowHistory(); + await vi.waitFor(() => expect(finishStaleRead).toBeDefined()); + + // The deleting session's own refresh sees the file, then removes + // it, and the handler propagates the deletion to the observer. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect( + deleting.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + session.noteExternalWorkflowDeletion(snapshot.runId); + expect(session.getWorkflowHistory()).toEqual([]); + + // The observer's read that began before the delete now completes + // with the pre-delete listing. + finishStaleRead([snapshot]); + await staleRefresh; + + expect(session.getWorkflowHistory()).toEqual([]); + + // A later refresh that genuinely finds the run again (a retry + // reuses the runId) is not suppressed by the old deletion. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await session.refreshWorkflowHistory(); + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_cross' }), + ]); + } finally { + deleting.dispose(); + } + }); + it('drops persisted workflow history deleted by another session', async () => { session.dispose(); const snapshot = { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 4b4fbd9d305..1965adc61a3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3504,6 +3504,31 @@ export class Session implements SessionContext { return true; } + /** + * A sibling session deleted `runId` from the shared store. The + * deletion-sequence marker is per-Session — it records deletions THIS + * session issued — while the store and the delete entrance are + * process-wide, so without this a refresh of ours that began reading + * the directory before the sibling's delete landed would merge the + * stale listing and republish the run the sibling's client was just + * told was gone. Called under the sibling's task-mutation claim, + * symmetric to the registry `removeTerminal` sweep. + * + * The R7-5 persisted latch is deliberately kept: a late terminal + * emission for the deleted run must still not re-insert it. + */ + noteExternalWorkflowDeletion(runId: string): void { + this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq); + this.unpersistedWorkflowHistory.delete(runId); + this.mergedWorkflowRunIds.delete(runId); + const retained = this.workflowHistory.filter( + (item) => item.runId !== runId, + ); + if (retained.length === this.workflowHistory.length) return; + this.workflowHistory = retained; + this.#activeWorkChanged(); + } + #rememberWorkflowHistory(entry: WorkflowTask): void { if (!isTerminalWorkflowStatus(entry.status)) { // Back in an active state means this runId was registered afresh @@ -3813,7 +3838,19 @@ export class Session implements SessionContext { if (shellActive) { holds.push({ category: 'shell', id: 'background-shells' }); } - for (const task of this.config.getWorkflowRunRegistry().list()) { + const workflowRegistry = this.config.getWorkflowRunRegistry(); + // A reserved-but-unregistered run (script loading, journal replay) + // has no `list()` entry yet, but the registry's hasRunningEntries() + // and the delete/cancel liveness gates already count it as live. A + // daemon-initiated conditional close that read no hold here would + // dispose the session and abort the start under the client that just + // asked for it. The hold releases itself: registration takes over + // with the entry's running hold, and a failed or cancelled start + // drops the reservation via `releaseStart`. + for (const runId of workflowRegistry.listStartingRunIds?.() ?? []) { + holds.push({ category: 'workflow', id: runId }); + } + for (const task of workflowRegistry.list()) { // Mirror the registry's hasRunningEntries(): a paused run executes // nothing and no backstop would ever release the hold, so it must // not pin the session the way executing work does. diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index ca3ffd8733f..c24fa7a9d82 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -292,9 +292,7 @@ describe('WorkflowRunner', () => { registry.abortAll(); resolveLoad?.({ results: new Map(), started: new Map() }); - await expect(start).rejects.toThrow( - 'Background workflow start was cancelled.', - ); + await expect(start).rejects.toThrow('Workflow start was cancelled.'); expect(registry.isStarting(runId)).toBe(false); expect(registry.get(runId)).toBeUndefined(); } finally { @@ -344,9 +342,7 @@ describe('WorkflowRunner', () => { registry.abortAll(); finishLoad?.(saved); - await expect(start).rejects.toThrow( - 'Background workflow start was cancelled.', - ); + await expect(start).rejects.toThrow('Workflow start was cancelled.'); // Typed, not a bare Error: the tool maps this to its "cancelled // before it could start" result even when the caller's own signal // is still live. diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index 27502688f48..4b3c87ff3fa 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -104,16 +104,17 @@ export class WorkflowScriptNotLaunchedError extends Error { } /** - * A background start that was cancelled before it registered — by the - * caller's signal, or by `WorkflowRunRegistry.cancelStarting` aborting the - * run's own controller while the caller's signal stayed live. The second - * source is why this is a class and not a bare `Error`: the tool cannot - * tell it from a genuine start failure by looking at the caller's signal, - * and would otherwise surface "cancelled" as an unexplained error. + * A start that was cancelled before it registered — a background start by + * the caller's signal, or a start in either mode by + * `WorkflowRunRegistry.cancelStarting` / `abortAll` aborting the run's own + * controller while the caller's signal stayed live. The second source is + * why this is a class and not a bare `Error`: the tool cannot tell it from + * a genuine start failure by looking at the caller's signal, and would + * otherwise surface "cancelled" as an unexplained error. */ export class WorkflowStartCancelledError extends Error { constructor() { - super('Background workflow start was cancelled.'); + super('Workflow start was cancelled.'); this.name = 'WorkflowStartCancelledError'; } } @@ -191,10 +192,19 @@ export class WorkflowRunner { resumeReplay = options.resumeFromRunId ? await journal?.load() : undefined; - if ( - runInBackground && - (controller.signal.aborted || options.signal.aborted) - ) { + // A registry-side cancel (`cancelStarting`, `abortAll`) aborts the + // reserved controller while the caller's signal stays live. It is a + // cancel in either mode: registering anyway would let the settlement + // classifier — which only knows the caller's signal and the entry's + // status — record the run as failed, or completed for a dispatch-free + // script, under a client that was just told `{cancelled: true}`. + if (controller.signal.aborted && !options.signal.aborted) { + throw new WorkflowStartCancelledError(); + } + // The caller's own abort is reported the same way for a background + // start; a foreground start registers and settles `cancelled` so the + // caller's tool result carries the run it asked for. + if (runInBackground && options.signal.aborted) { throw new WorkflowStartCancelledError(); } callerWasAbortedBeforeStart = options.signal.aborted; diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 302f31b4004..0fc66c1d8e0 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -623,6 +623,17 @@ export class WorkflowRunRegistry { return this.starting.has(runId); } + /** + * Run ids reserved by `reserveStart` and not yet registered. A session + * reports these as active-work holds: `list()` has no entry for the + * starting window, and a daemon that judged the session idle from + * `list()` alone would close it and abort the start under the client + * that just asked for it. + */ + listStartingRunIds(): string[] { + return [...this.starting.keys()]; + } + /** * Cancel a run that has been reserved but not yet registered. Aborts * the reserved controller only — the reservation itself is the diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index ab56abbe00f..5e237698fba 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -774,6 +774,50 @@ await agent('scan package.json') } }); + it('reports a registry-side cancel during foreground preflight as cancelled, not failed', async () => { + // The foreground path is the tool's default mode, and the same + // registry-side sources reach it: `sessionTaskCancel` fires + // `cancelStarting` on a resume whose terminal entry was evicted from + // the registry, and `abortAll` on session dispose. Registering anyway + // let the settlement classifier — blind to the run's own controller — + // settle the run `completed` for this dispatch-free script. + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { + storage: new Storage(path.join(os.tmpdir(), 'workflow-preflight-test')), + isInteractive: () => true, + getWorkflowRunRegistry: () => registry, + } as unknown as Config; + const caller = new AbortController(); + const dispatch = vi.fn(async () => 'unused'); + const load = vi + .spyOn(WorkflowJournal.prototype, 'load') + .mockImplementation(async () => { + expect(registry.cancelStarting('wf_1234abcd')).toBe(true); + return { results: new Map(), started: new Map() }; + }); + + try { + const result = await new WorkflowTool(config, { dispatch }) + .build({ + script: 'return 1', + resumeFromRunId: 'wf_1234abcd', + }) + .execute(caller.signal); + + expect(caller.signal.aborted).toBe(false); + expect(result).toEqual({ + llmContent: 'Workflow was cancelled before it could start.', + returnDisplay: 'Workflow cancelled.', + }); + expect(registry.list()).toHaveLength(0); + expect(registry.isStarting('wf_1234abcd')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + }); + it('does not register when cancellation arrives during background preflight', async () => { const registry = new WorkflowRunRegistry(); registry.setCompletionCallback(vi.fn()); diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index 0795e7dfc4b..00b54aa0468 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -362,7 +362,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< ): Promise { const runInBackground = this.params.run_in_background === true; if (runInBackground && signal.aborted) { - return backgroundStartCancelledResult(); + return startCancelledResult(); } let handle: WorkflowRunHandle; try { @@ -382,16 +382,18 @@ class WorkflowToolInvocation extends BaseToolInvocation< : undefined, }); } catch (error) { - // Two cancel sources reach a background start: the caller's own - // signal, and a registry-side cancel (`cancelStarting`) that aborts - // the run's controller while the caller's signal stays live. The - // runner reports the latter with a typed error; both are the same - // outcome to the model. + // Two cancel sources reach a start before it registers: the caller's + // own signal (background only — a foreground start registers and + // settles `cancelled` instead), and a registry-side cancel + // (`cancelStarting`, `abortAll`) that aborts the run's controller + // while the caller's signal stays live, in either mode. The runner + // reports the latter with a typed error; both are the same outcome + // to the model. if ( - runInBackground && - (signal.aborted || error instanceof WorkflowStartCancelledError) + error instanceof WorkflowStartCancelledError || + (runInBackground && signal.aborted) ) { - return backgroundStartCancelledResult(); + return startCancelledResult(); } // A script that never compiled has no run behind it, so reporting it as // a failed workflow would be wrong twice: it invites the model to go @@ -531,7 +533,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< } } -function backgroundStartCancelledResult(): WorkflowToolResult { +function startCancelledResult(): WorkflowToolResult { return { llmContent: 'Workflow was cancelled before it could start.', returnDisplay: 'Workflow cancelled.', From 8bdd956494160d2ce8946fb86577f2dc02c1d2af Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 18:35:18 +0800 Subject: [PATCH 14/23] fix(serve): keep workflow retry and the workflowsEnabled flag consistent across sessions Two cross-session gaps in the workflow control surface: - A retry consulted only the requesting session's registry. Every session shares one journal/snapshot store and the task-global claim is released as soon as the background start returns, so a sibling whose registry still showed the run `failed` started a second runner under the same runId. Retry now refuses while the runId is live in any session (or in its own starting window), checked synchronously beside canStart so the answer cannot go stale before the claim is taken. - Workspace reload updated `tools.workflowsEnabled` for `/capabilities` but never told existing sessions; `Config.workflowsEnabled` was set once at construction. The reload's `tools` branch now propagates the flag and pushes an available-commands update when it flips. Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT --- .../cli/src/acp-integration/acpAgent.test.ts | 208 ++++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 41 +++- 2 files changed, 248 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index f5ad6b550b5..c94319c68bc 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -4096,6 +4096,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), isWorkflowsEnabled: vi.fn().mockReturnValue(false), + setWorkflowsEnabled: vi.fn(), getBareMode: vi.fn().mockReturnValue(false), getFolderTrustFeature: vi.fn().mockReturnValue(false), getFolderTrust: vi.fn().mockReturnValue(true), @@ -12467,6 +12468,106 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('refuses a retry while a sibling session still runs the same runId', async () => { + // R11-1: canStart used to consult only the requesting session's + // private registry, and the task-global claim is released as soon as + // the background start returns — while the run is still live. A + // sibling whose registry still shows the run `failed` with no handle + // therefore started a second runner under the same runId over the + // shared journal and snapshot files. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_live1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + const registryA = { + get: vi.fn().mockReturnValue({ runId, status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }; + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryA), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const execute = vi.fn(async () => ({ + llmContent: 'started', + workflowRunId: runId, + })); + let bStarting = false; + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + runId, + status: 'failed', + script: 'return await agent("x")', + args: undefined, + }), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn(() => bStarting), + removeTerminal: vi.fn().mockReturnValue(false), + }), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' + ? { buildSessionOwnedBackground: vi.fn(() => ({ execute })) } + : undefined, + ), + }), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const retry = () => + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdB, + taskId: runId, + action: 'retry', + }); + + // Session A still runs it: B's retry must refuse without starting. + await expect(retry()).resolves.toEqual({ + changed: false, + status: 'failed', + }); + expect(execute).not.toHaveBeenCalled(); + + // A's run settled, but B's own earlier retry is still in its + // starting window (reservation held, no entry yet): still refused. + registryA.get.mockReturnValue({ runId, status: 'failed' }); + bStarting = true; + await expect(retry()).resolves.toEqual({ + changed: false, + status: 'failed', + }); + expect(execute).not.toHaveBeenCalled(); + + // Nothing live anywhere: the retry goes through. + bStarting = false; + await expect(retry()).resolves.toMatchObject({ changed: true }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('wires history-deletion liveness across every sibling session registry', async () => { const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; @@ -24177,6 +24278,8 @@ describe('sessionLanguage multi-session propagation', () => { refreshHierarchicalMemory: vi.fn().mockResolvedValue(undefined), getWorkspaceContext: vi.fn().mockReturnValue({}), getDebugMode: vi.fn().mockReturnValue(false), + isWorkflowsEnabled: vi.fn().mockReturnValue(false), + setWorkflowsEnabled: vi.fn(), ...overrides, }; } @@ -24624,6 +24727,111 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); + it('propagates tools.workflowsEnabled to existing sessions on reload', async () => { + // R11-2: /capabilities reads the flag live from the reloaded + // settings, but a session alive before the reload was constructed + // with the old value. Without propagation its control surfaces kept + // answering canUseWorkflowControls with the stale value while the + // capabilities advertisement said the opposite. + let mergedSettings: Record = { + tools: { workflowsEnabled: true }, + }; + const settings = { + get merged() { + return mergedSettings; + }, + reloadScopeFromDisk: vi.fn(() => { + mergedSettings = { tools: { workflowsEnabled: false } }; + }), + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + let workflowsEnabled = true; + const registryCancel = vi.fn(); + const cfg = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-wf-reload'), + isWorkflowsEnabled: vi.fn(() => workflowsEnabled), + setWorkflowsEnabled: vi.fn((enabled: boolean) => { + workflowsEnabled = enabled; + }), + setDisabledTools: vi.fn(), + getBareMode: vi.fn().mockReturnValue(false), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId: 'wf_1', status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + cancel: registryCancel, + }), + }); + const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); + + vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('s-wf-reload'), + shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), + getConfig: vi.fn().mockReturnValue(cfg), + isIdle: vi.fn().mockReturnValue(true), + sendAvailableCommandsUpdate, + installRewriter: vi.fn(), + installGoalTerminalObserver: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + settings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/reload', mcpServers: [] }); + const cancelWorkflow = () => + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId: 's-wf-reload', + taskId: 'wf_1', + taskKind: 'workflow', + }); + await expect(cancelWorkflow()).resolves.toMatchObject({ cancelled: true }); + expect(registryCancel).toHaveBeenCalledOnce(); + + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceReload, + {}, + ); + + expect(result).toMatchObject({ sessionsRefreshed: ['s-wf-reload'] }); + expect( + (cfg as typeof cfg & { setWorkflowsEnabled: ReturnType }) + .setWorkflowsEnabled, + ).toHaveBeenCalledWith(false); + // The `workflows` command left with the flag: the client is told. + expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); + // The existing session now answers with the reloaded value. + await expect(cancelWorkflow()).resolves.toEqual({ + cancelled: false, + reason: 'disabled', + }); + expect(registryCancel).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('refreshes busy skill sessions and reports per-session failures', async () => { const bootstrapSettings = { merged: {}, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 2a2b192a005..8f22c6d4ff7 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -11057,9 +11057,24 @@ class QwenAgent implements Agent { const task = registry.get(taskId); if (!task) return { changed: false }; if (action === 'retry' || action === 'rerun') { + // A retry reuses its runId over the one journal/snapshot store + // every session shares, so "failed with no handle" in THIS + // session's registry is not enough: a sibling may have retried + // it already and be running it now — the task-global claim is + // released as soon as the background start returns, while the + // run is still live. Two runners on one runId interleave the + // journal and race the snapshot write. Checked synchronously + // beside canStart — no await precedes the claim — so the answer + // cannot go stale before the claim is taken. + const liveElsewhere = + action === 'retry' && + (registry.isStarting?.(taskId) === true || + this.isWorkflowRunLiveOutsideSession(sessionId, taskId)); const canStart = action === 'retry' - ? task.status === 'failed' && !registry.getHandle(taskId) + ? task.status === 'failed' && + !registry.getHandle(taskId) && + !liveElsewhere : task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'; @@ -12372,6 +12387,30 @@ class QwenAgent implements Agent { ); config.setDisabledTools(new Set(disabled)); + // `/capabilities` reads `tools.workflowsEnabled` live from + // the reloaded settings; a session alive before the reload + // was constructed with the old value and would keep + // answering canUseWorkflowControls with it, so the + // advertisement and the controls it gates would diverge. + // The merged view is already workspace-stripped, so a repo + // cannot self-grant here any more than at construction. + const workflowsWereEnabled = config.isWorkflowsEnabled(); + config.setWorkflowsEnabled( + newMerged.tools?.workflowsEnabled === true, + ); + if (config.isWorkflowsEnabled() !== workflowsWereEnabled) { + // The `workflows` slash command comes and goes with the + // flag; a client holding the old list would keep offering + // (or hiding) it. + try { + await session.sendAvailableCommandsUpdate(); + } catch (err) { + debugLogger.warn( + `reload: sendAvailableCommandsUpdate failed for session ${id}: ${err}`, + ); + } + } + const newMode = newMerged.tools?.approvalMode; if ( newMode && From 4183ee7f24a0446572a4cb9557a28577bd54f314 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 21:21:50 +0800 Subject: [PATCH 15/23] fix(serve): refuse to report a workflow history deletion whose registry entry survived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deleteWorkflowHistory` ignored `removeTerminal()`'s answer. The registry refuses to remove a live or handle-held entry — its own last word on whether the run is still active in this session — so a `false` for an entry that exists meant the run re-registered under the deletion and would re-persist the history the client was just told was gone. The entry is now retired before the store is touched, and a refusal fails the deletion; a persisted-only run has no entry and is unaffected. Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT --- .../acp-integration/session/Session.test.ts | 54 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 9 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ff8ef3433de..6af51c1449a 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -3445,6 +3445,7 @@ describe('Session', () => { runId: target.runId, status: 'failed', }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); listWorkflowSnapshotsSpy.mockResolvedValueOnce([...newer, target]); await expect( @@ -3518,6 +3519,58 @@ describe('Session', () => { ); }); + it('does not report a deletion whose registry entry could not be retired', async () => { + // `removeTerminal` refuses a live or handle-held entry. Ignoring its + // answer reported success for a run that was still registered here, + // whose settlement then re-persisted the "deleted" history. + session.dispose(); + const snapshot = { + runId: 'wf_stuck', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + mockWorkflowRunRegistry.get.mockReturnValue({ + runId: snapshot.runId, + status: 'failed', + }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(false); + + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + false, + ); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + expect(session.getWorkflowHistory()).toHaveLength(1); + + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + true, + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + it('keeps cached history on disk failure and removes it after deletion', async () => { session.dispose(); const onActiveWorkChanged = vi.fn(); @@ -3678,6 +3731,7 @@ describe('Session', () => { ); listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); const deletion = session.deleteWorkflowHistory(snapshot.runId); await vi.waitFor(() => diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 062bdf94a99..2f106784b85 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3516,9 +3516,16 @@ export class Session implements SessionContext { ) { return false; } + // Retire the registry entry before touching the store. `removeTerminal` + // refuses a live or handle-held entry — the registry's own last word + // on whether the run is still active here — so `false` for an entry + // that exists means the run re-registered and must not be reported + // deleted; a persisted-only run has no entry to retire. + if (registry.get(runId) !== undefined && !registry.removeTerminal(runId)) { + return false; + } if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq); - registry.removeTerminal(runId); this.unpersistedWorkflowHistory.delete(runId); this.mergedWorkflowRunIds.delete(runId); this.persistedWorkflowRunIds.delete(runId); From 4c68068e083826e9597fa5771505ee2e0289b2a0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 22:04:09 +0800 Subject: [PATCH 16/23] docs(serve): fix a stale method reference in the workflow history comment The persisted latch is released inline at the top of #rememberWorkflowHistory; no #forgetPersistedWorkflowRun exists. Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT --- packages/cli/src/acp-integration/session/Session.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2f106784b85..b418986eaf7 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3580,9 +3580,9 @@ export class Session implements SessionContext { // and a sibling session's deletion was then undone by the next // refresh: absent on disk but present in the stale cache reads as a // pending write, so the deleted run was republished and stayed for - // the life of the session. Cleared on re-registration - // (`#forgetPersistedWorkflowRun`) so a genuine re-run of the same - // runId is remembered again. + // the life of the session. Released at the top of this method when + // the runId comes back non-terminal (a retry/resume re-registers it) + // so a genuine re-run of the same runId is remembered again. if (this.persistedWorkflowRunIds.has(entry.runId)) return; const snapshot = toSnapshot(entry); this.unpersistedWorkflowHistory.set(snapshot.runId, snapshot); From 7a887232c1d40d77f5b77127e088a1aa360855a1 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:24:34 +0800 Subject: [PATCH 17/23] fix: report workflow lifecycle changes --- .../src/ui/utils/backgroundWorkUtils.test.ts | 12 +++++++++++ .../cli/src/ui/utils/backgroundWorkUtils.ts | 20 +++++++++++++++---- .../src/agents/workflow-run-registry.test.ts | 3 +++ .../core/src/agents/workflow-run-registry.ts | 7 ++++--- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts index 0b5788754d0..d1e1388cdca 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts @@ -152,6 +152,7 @@ function createEnumeratingMockConfig(overrides?: { monitors?: unknown[]; shells?: unknown[]; workflows?: unknown[]; + startingWorkflows?: string[]; }): Config { return { getBackgroundTaskRegistry: () => ({ @@ -165,6 +166,7 @@ function createEnumeratingMockConfig(overrides?: { }), getWorkflowRunRegistry: () => ({ list: () => overrides?.workflows ?? [], + listStartingRunIds: () => overrides?.startingWorkflows ?? [], }), } as unknown as Config; } @@ -285,6 +287,16 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(summary?.hasWorkflowRuns).toBe(true); }); + it('lists workflow runs that are still starting', () => { + const summary = describeBlockingBackgroundWork( + createEnumeratingMockConfig({ startingWorkflows: ['wf_starting'] }), + ); + + expect(summary?.lines).toEqual([' [wf_starting] wf_starting (starting)']); + expect(summary?.hasTaskEntries).toBe(false); + expect(summary?.hasWorkflowRuns).toBe(true); + }); + it('sorts lines by start time', () => { const config = createEnumeratingMockConfig({ shells: [ diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.ts index 56892a8b4fe..b2117556728 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.ts @@ -74,10 +74,10 @@ export interface BlockingBackgroundWork { * Enumerates the entries that make `hasBlockingBackgroundWork()` true, * mirroring its per-registry predicate exactly (background agents: * `isBackgrounded` + `running`; monitors: `running`; shells: `running`; - * workflow runs: `running` or `pausing`). Returns `undefined` when nothing - * is enumerated — e.g. an entry settled between the gate check and this - * call — so callers fall back to their base message instead of rendering - * an empty list. + * workflow runs: `starting`, `running`, or `pausing`). Returns `undefined` + * when nothing is enumerated — e.g. an entry settled between the gate check + * and this call — so callers fall back to their base message instead of + * rendering an empty list. */ export function describeBlockingBackgroundWork( config: Config, @@ -89,6 +89,7 @@ export function describeBlockingBackgroundWork( label: string; status: TaskStatus | WorkflowStatus; isWorkflowRun: boolean; + isStarting?: boolean; }> = []; for (const entry of config.getBackgroundTaskRegistry().getAll()) { @@ -130,6 +131,16 @@ export function describeBlockingBackgroundWork( isWorkflowRun: true, }); } + for (const runId of config.getWorkflowRunRegistry().listStartingRunIds()) { + entries.push({ + startTime: now, + id: runId, + label: runId, + status: 'running', + isWorkflowRun: true, + isStarting: true, + }); + } if (entries.length === 0) return undefined; @@ -140,6 +151,7 @@ export function describeBlockingBackgroundWork( stripUnsafeCharacters(entry.label).replace(/[\r\n]+/g, ' '), MAX_BLOCKING_LABEL_WIDTH, ); + if (entry.isStarting) return ` [${entry.id}] ${label} (starting)`; return ` [${entry.id}] ${label} (${entry.status} ${formatDuration( now - entry.startTime, { hideTrailingZeros: true }, diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index 514c8c00216..4a93bd81dc5 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -1817,10 +1817,13 @@ describe('WorkflowRunRegistry', () => { expect(r.get(held.runId)).toBe(held); expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS + 1); + const statusChange = vi.fn(); + r.setStatusChangeCallback(statusChange); r.releaseHandle(held.runId, handle); expect(r.get(held.runId)).toBeUndefined(); expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS); + expect(statusChange.mock.calls).toEqual([[undefined]]); }); it('active entries are never evicted', () => { diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 0fc66c1d8e0..e1326a4e64c 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -761,7 +761,7 @@ export class WorkflowRunRegistry { releaseHandle(runId: string, handle: WorkflowRunHandle): void { if (this.handles.get(runId) !== handle) return; this.handles.delete(runId); - this.evictTerminal(); + if (this.evictTerminal()) this.emitStatusChange(); } bridgeApprovalEvents( @@ -1527,13 +1527,13 @@ export class WorkflowRunRegistry { * Active entries are always retained. Oldest terminal entries * (by `endTime`) are evicted first. */ - private evictTerminal(): void { + private evictTerminal(): boolean { const terminal = this.list().filter( (entry) => isTerminalWorkflowStatus(entry.status) && !this.handles.has(entry.runId), ); - if (terminal.length <= MAX_RETAINED_TERMINAL_WORKFLOWS) return; + if (terminal.length <= MAX_RETAINED_TERMINAL_WORKFLOWS) return false; terminal.sort((a, b) => (a.endTime ?? 0) - (b.endTime ?? 0)); const toEvict = terminal.slice( 0, @@ -1542,6 +1542,7 @@ export class WorkflowRunRegistry { for (const e of toEvict) { this.entries.delete(e.runId); } + return true; } private emitStatusChange(entry?: WorkflowTask): void { From 0653b429cb66d0959e341227d5684f20b9a53ad5 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 29 Aug 2026 19:35:47 +0800 Subject: [PATCH 18/23] fix(core): repair the workflow test doubles and converge every eviction The previous commit added a `listStartingRunIds()` call to `describeBlockingBackgroundWork` without teaching the registry test doubles about it, so seven tests across `clearCommand`, `useBranchCommand` and `useResumeCommand` threw `listStartingRunIds is not a function`. Add the reader to those stubs. Also move the eviction's status emission from `releaseHandle` into `evictTerminal` itself. Emitting only from `releaseHandle` left the same convergence gap at the four sweeping call sites that already emit: complete / fail / cancel / abortAll emit BEFORE they sweep, so a consumer that re-reads on the callback observes the pre-eviction list and keeps rendering a row that was just dropped. Emitting once after the sweep closes all five paths, and a future eviction site inherits the guarantee. New test pins the ordering; another pins that a release which evicts nothing stays silent. A starting reservation now reports `starting` rather than borrowing `running`, and its line no longer repeats the run id the bracket already carries. Claude-Session: https://claude.ai/code/session_01VENc5rZYmMJDdqvjwBEyZd --- .../cli/src/ui/commands/clearCommand.test.ts | 12 +++++ .../cli/src/ui/hooks/useBranchCommand.test.ts | 2 + .../cli/src/ui/hooks/useResumeCommand.test.ts | 16 +++++++ .../src/ui/utils/backgroundWorkUtils.test.ts | 44 ++++++++++++++++++- .../cli/src/ui/utils/backgroundWorkUtils.ts | 18 ++++++-- .../src/agents/workflow-run-registry.test.ts | 41 +++++++++++++++++ .../core/src/agents/workflow-run-registry.ts | 19 ++++++-- 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 4ddc7ac5dde..c204e076c10 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -92,6 +92,8 @@ describe('clearCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -350,6 +352,8 @@ describe('clearCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -428,6 +432,8 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), + list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -501,6 +507,8 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), + list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -570,6 +578,8 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), + list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -640,6 +650,8 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), + list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts index cdfe3479816..d12fe7425dd 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.test.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -50,6 +50,7 @@ describe('useBranchCommand', () => { let workflowRunRegistry: { hasRunningEntries: ReturnType; list: ReturnType; + listStartingRunIds: ReturnType; reset: ReturnType; abortAll: ReturnType; }; @@ -126,6 +127,7 @@ describe('useBranchCommand', () => { workflowRunRegistry = { hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }; diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index a3636eecffd..c427e46642a 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -52,6 +52,8 @@ function makeSwapSlotConfig(llmClient: SwapSlotClient) { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -313,6 +315,8 @@ describe('useResumeCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -415,6 +419,8 @@ describe('useResumeCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -494,6 +500,8 @@ describe('useResumeCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -578,6 +586,8 @@ describe('useResumeCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -674,6 +684,8 @@ describe('useResumeCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -749,6 +761,7 @@ describe('useResumeCommand', () => { getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -824,6 +837,7 @@ describe('useResumeCommand', () => { getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -893,6 +907,8 @@ describe('useResumeCommand', () => { }), getWorkflowRunRegistry: () => ({ hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts index d1e1388cdca..635bec5cdb5 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts @@ -292,11 +292,53 @@ describe('describeBlockingBackgroundWork (#8741)', () => { createEnumeratingMockConfig({ startingWorkflows: ['wf_starting'] }), ); - expect(summary?.lines).toEqual([' [wf_starting] wf_starting (starting)']); + expect(summary?.lines).toEqual([' [wf_starting] (starting)']); expect(summary?.hasTaskEntries).toBe(false); expect(summary?.hasWorkflowRuns).toBe(true); }); + it('renders a starting run without a fabricated duration', () => { + const summary = describeBlockingBackgroundWork( + createEnumeratingMockConfig({ startingWorkflows: ['wf_a', 'wf_b'] }), + ); + expect(summary?.lines).toHaveLength(2); + for (const line of summary!.lines) { + // A reservation has no registered startTime; printing one would + // report a duration the run has not run for. + expect(line).not.toMatch(/\d+s\)/); + expect(line).toMatch(/\(starting\)$/); + } + }); + + it('lists a starting run alongside a registered one, newest last', () => { + const summary = describeBlockingBackgroundWork( + createEnumeratingMockConfig({ + workflows: [ + { + runId: 'wf_running', + status: 'running', + meta: { name: 'build' }, + startTime: 1_000, + }, + ], + startingWorkflows: ['wf_starting'], + }), + ); + expect(summary?.lines).toHaveLength(2); + expect(summary!.lines[0]).toContain('[wf_running]'); + expect(summary!.lines[1]).toBe(' [wf_starting] (starting)'); + }); + + it('points /clear at /workflows when only a starting run blocks', () => { + const message = buildBackgroundWorkBlockedMessage( + createEnumeratingMockConfig({ startingWorkflows: ['wf_starting'] }), + 'Cannot clear.', + ); + expect(message).toContain('Cannot clear.'); + expect(message).toContain(' [wf_starting] (starting)'); + expect(message).toContain('Use /workflows to inspect them, then retry.'); + }); + it('sorts lines by start time', () => { const config = createEnumeratingMockConfig({ shells: [ diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.ts index b2117556728..851acbb2a66 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.ts @@ -74,7 +74,8 @@ export interface BlockingBackgroundWork { * Enumerates the entries that make `hasBlockingBackgroundWork()` true, * mirroring its per-registry predicate exactly (background agents: * `isBackgrounded` + `running`; monitors: `running`; shells: `running`; - * workflow runs: `starting`, `running`, or `pausing`). Returns `undefined` + * workflow runs: `running` or `pausing`, plus the reserved-but- + * unregistered runs `hasRunningEntries()` counts via `starting`). Returns `undefined` * when nothing is enumerated — e.g. an entry settled between the gate check * and this call — so callers fall back to their base message instead of * rendering an empty list. @@ -87,7 +88,7 @@ export function describeBlockingBackgroundWork( startTime: number; id: string; label: string; - status: TaskStatus | WorkflowStatus; + status: TaskStatus | WorkflowStatus | 'starting'; isWorkflowRun: boolean; isStarting?: boolean; }> = []; @@ -131,12 +132,19 @@ export function describeBlockingBackgroundWork( isWorkflowRun: true, }); } + // `hasRunningEntries()` also counts runs reserved by `reserveStart` and + // not yet registered (journal replay on retry, saved-workflow background + // start). `list()` never contains them, so the gate was true with nothing + // to name: during the starting window /clear, /branch and /resume were + // refused with the bare base message and no blocker. A reservation has + // no registered startTime and no meta yet, so it sorts as the newest + // entry and renders without a duration. for (const runId of config.getWorkflowRunRegistry().listStartingRunIds()) { entries.push({ startTime: now, id: runId, label: runId, - status: 'running', + status: 'starting', isWorkflowRun: true, isStarting: true, }); @@ -151,7 +159,9 @@ export function describeBlockingBackgroundWork( stripUnsafeCharacters(entry.label).replace(/[\r\n]+/g, ' '), MAX_BLOCKING_LABEL_WIDTH, ); - if (entry.isStarting) return ` [${entry.id}] ${label} (starting)`; + // The label of a reservation is its run id, which the bracket already + // carries; printing it twice reads as a duplicated row. + if (entry.isStarting) return ` [${entry.id}] (starting)`; return ` [${entry.id}] ${label} (${entry.status} ${formatDuration( now - entry.startTime, { hideTrailingZeros: true }, diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index 4a93bd81dc5..de4a9a812dc 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -1826,6 +1826,47 @@ describe('WorkflowRunRegistry', () => { expect(statusChange.mock.calls).toEqual([[undefined]]); }); + it('does not emit on a handle release that evicts nothing', () => { + const r = new WorkflowRunRegistry(); + const held = r.register(reg('wf_held')); + const handle = { + runId: held.runId, + abort: vi.fn(), + } as unknown as WorkflowRunHandle; + r.attachHandle(handle); + r.complete(held.runId, null, 1_000); + + const statusChange = vi.fn(); + r.setStatusChangeCallback(statusChange); + r.releaseHandle(held.runId, handle); + + // Under the retention cap nothing is swept, so the release is not a + // row-removing mutation and must stay silent. + expect(statusChange).not.toHaveBeenCalled(); + expect(r.get(held.runId)).toBe(held); + }); + + it('emits after the sweep, so a consumer reads the post-eviction list', () => { + const r = new WorkflowRunRegistry(); + for (let i = 0; i < MAX_RETAINED_TERMINAL_WORKFLOWS; i++) { + r.register(reg(`wf_${i}`)); + r.complete(`wf_${i}`, null, 1_000 + i); + } + + const seen: number[] = []; + r.setStatusChangeCallback(() => { + seen.push(r.list().length); + }); + // complete() emits BEFORE it sweeps, so without the trailing + // eviction emission a consumer only ever observes the over-cap list + // and keeps rendering the row that was just dropped. + r.register(reg('wf_overflow')); + r.complete('wf_overflow', null, 9_000); + + expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS); + expect(seen.at(-1)).toBe(MAX_RETAINED_TERMINAL_WORKFLOWS); + }); + it('active entries are never evicted', () => { const r = new WorkflowRunRegistry(); r.register(reg('runner')); diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index e1326a4e64c..3ac33a34ece 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -761,7 +761,7 @@ export class WorkflowRunRegistry { releaseHandle(runId: string, handle: WorkflowRunHandle): void { if (this.handles.get(runId) !== handle) return; this.handles.delete(runId); - if (this.evictTerminal()) this.emitStatusChange(); + this.evictTerminal(); } bridgeApprovalEvents( @@ -1527,13 +1527,13 @@ export class WorkflowRunRegistry { * Active entries are always retained. Oldest terminal entries * (by `endTime`) are evicted first. */ - private evictTerminal(): boolean { + private evictTerminal(): void { const terminal = this.list().filter( (entry) => isTerminalWorkflowStatus(entry.status) && !this.handles.has(entry.runId), ); - if (terminal.length <= MAX_RETAINED_TERMINAL_WORKFLOWS) return false; + if (terminal.length <= MAX_RETAINED_TERMINAL_WORKFLOWS) return; terminal.sort((a, b) => (a.endTime ?? 0) - (b.endTime ?? 0)); const toEvict = terminal.slice( 0, @@ -1542,7 +1542,18 @@ export class WorkflowRunRegistry { for (const e of toEvict) { this.entries.delete(e.runId); } - return true; + // Eviction is a row-removing mutation like every other one, and the + // consumers that render these rows (tasks dialog, `/workflows` + // roster) re-read the registry only when a status change is + // emitted. Two paths reach here without a usable emission: + // `releaseHandle` emits nothing of its own, and complete / fail / + // cancel / abortAll emit BEFORE sweeping, so a synchronous consumer + // reads the pre-eviction list. Either way the roster kept showing + // an evicted row until some unrelated status change fired. Emit + // once here, after the sweep, so every eviction converges on its + // own — and so a future eviction site inherits the guarantee + // instead of having to remember it. + this.emitStatusChange(); } private emitStatusChange(entry?: WorkflowTask): void { From c1c0072201df263d4505ea58fc5c45408d6fe55b Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 29 Aug 2026 22:26:47 +0800 Subject: [PATCH 19/23] fix(cli): drop the duplicated mock keys and stop misdirecting a starting run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3-2: the previous commit's stub plumbing re-inserted a `list:` line into four `clearCommand.test.ts` registry mocks that already declared one. Last-wins made it behaviour-neutral, and neither eslint (`no-dupe-keys` is off here) nor tsc (the literal is not contextually typed, unlike the `useResumeCommand` mocks where TS1117 did fire) caught it. Remove the duplicates. R3-1: when the only blocker is a reserved-but-unregistered run, the blocked message ended with "Use /workflows to inspect them, then retry." — but `/workflows` renders `registry.list()`, which a reservation has not entered, so it named a surface that cannot show what is blocking. Track inspectability separately and fall back to a plain retry hint when nothing is listable; a registered run in the same set still points at `/workflows`. Claude-Session: https://claude.ai/code/session_01VENc5rZYmMJDdqvjwBEyZd --- .../cli/src/ui/commands/clearCommand.test.ts | 4 ---- .../src/ui/utils/backgroundWorkUtils.test.ts | 23 ++++++++++++++++++- .../cli/src/ui/utils/backgroundWorkUtils.ts | 21 +++++++++++++---- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index c204e076c10..c4e741835c6 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -433,7 +433,6 @@ describe('clearCommand', () => { hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), listStartingRunIds: vi.fn().mockReturnValue([]), - list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -508,7 +507,6 @@ describe('clearCommand', () => { hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), listStartingRunIds: vi.fn().mockReturnValue([]), - list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -579,7 +577,6 @@ describe('clearCommand', () => { hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), listStartingRunIds: vi.fn().mockReturnValue([]), - list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -651,7 +648,6 @@ describe('clearCommand', () => { hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), listStartingRunIds: vi.fn().mockReturnValue([]), - list: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts index 635bec5cdb5..1331411a2a6 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts @@ -329,13 +329,34 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(summary!.lines[1]).toBe(' [wf_starting] (starting)'); }); - it('points /clear at /workflows when only a starting run blocks', () => { + it('does not send /clear to /workflows for a run it cannot show', () => { const message = buildBackgroundWorkBlockedMessage( createEnumeratingMockConfig({ startingWorkflows: ['wf_starting'] }), 'Cannot clear.', ); expect(message).toContain('Cannot clear.'); expect(message).toContain(' [wf_starting] (starting)'); + // `/workflows` reads registry.list(), which a reservation has not + // entered — pointing there would name a list that cannot show it. + expect(message).not.toContain('/workflows'); + expect(message).toContain('Retry once the run has finished starting.'); + }); + + it('still points at /workflows when a registered run also blocks', () => { + const message = buildBackgroundWorkBlockedMessage( + createEnumeratingMockConfig({ + workflows: [ + { + runId: 'wf_running', + status: 'running', + meta: { name: 'build' }, + startTime: 1_000, + }, + ], + startingWorkflows: ['wf_starting'], + }), + 'Cannot clear.', + ); expect(message).toContain('Use /workflows to inspect them, then retry.'); }); diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.ts index 851acbb2a66..35d0667a195 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.ts @@ -68,6 +68,10 @@ export interface BlockingBackgroundWork { /** True when at least one blocking entry is a workflow run (listed via * `/workflows`, not `/tasks`). */ hasWorkflowRuns: boolean; + /** True when at least one blocking workflow run is actually inspectable on + * `/workflows`. A reserved-but-unregistered run is not: that command reads + * `registry.list()`, which the reservation has not entered yet. */ + hasInspectableWorkflowRuns: boolean; } /** @@ -175,6 +179,9 @@ export function describeBlockingBackgroundWork( lines, hasTaskEntries: entries.some((entry) => !entry.isWorkflowRun), hasWorkflowRuns: entries.some((entry) => entry.isWorkflowRun), + hasInspectableWorkflowRuns: entries.some( + (entry) => entry.isWorkflowRun && !entry.isStarting, + ), }; } @@ -182,8 +189,9 @@ export function describeBlockingBackgroundWork( * Builds the session-switch blocked error: the caller's base message plus * one line per blocking entry and a pointer to the command that lists * each kind (`/tasks` for agents/shells/monitors, `/workflows` for - * workflow runs). Falls back to the bare base message when nothing is - * enumerated (see `describeBlockingBackgroundWork`). + * registered workflow runs; a still-starting run is on neither, so it + * gets a plain retry hint instead). Falls back to the bare base message + * when nothing is enumerated (see `describeBlockingBackgroundWork`). */ export function buildBackgroundWorkBlockedMessage( config: Config, @@ -193,11 +201,16 @@ export function buildBackgroundWorkBlockedMessage( if (!blocking) return baseMessage; const surfaces = [ ...(blocking.hasTaskEntries ? ['/tasks'] : []), - ...(blocking.hasWorkflowRuns ? ['/workflows'] : []), + ...(blocking.hasInspectableWorkflowRuns ? ['/workflows'] : []), ].join(' and '); return [ baseMessage, ...blocking.lines, - `Use ${surfaces} to inspect them, then retry.`, + // A reservation is on no surface yet — `/workflows` reads + // `registry.list()`, which it has not entered — so pointing there would + // send the user to a list that cannot show what is blocking them. + surfaces + ? `Use ${surfaces} to inspect them, then retry.` + : 'Retry once the run has finished starting.', ].join('\n'); } From dbf7cb1057890a2f1f4443530108c0bc31f7de8f Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:21:44 +0800 Subject: [PATCH 20/23] fix(cli): preserve starting workflow guidance --- .../src/ui/utils/backgroundWorkUtils.test.ts | 27 ++++++++++++++++--- .../cli/src/ui/utils/backgroundWorkUtils.ts | 16 +++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts index 1331411a2a6..1144482ea93 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts @@ -233,7 +233,7 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(summary?.lines[0]).toContain('Explore: research the codebase'); expect(summary?.lines[0]).toContain('(running 21h)'); expect(summary?.hasTaskEntries).toBe(true); - expect(summary?.hasWorkflowRuns).toBe(false); + expect(summary?.hasStartingWorkflowRuns).toBe(false); }); it('lists monitors, running shells, and running/pausing workflow runs', () => { @@ -284,7 +284,7 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(joined).not.toContain('shell_2'); expect(joined).not.toContain('wf_3'); expect(summary?.hasTaskEntries).toBe(true); - expect(summary?.hasWorkflowRuns).toBe(true); + expect(summary?.hasStartingWorkflowRuns).toBe(false); }); it('lists workflow runs that are still starting', () => { @@ -294,7 +294,7 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(summary?.lines).toEqual([' [wf_starting] (starting)']); expect(summary?.hasTaskEntries).toBe(false); - expect(summary?.hasWorkflowRuns).toBe(true); + expect(summary?.hasStartingWorkflowRuns).toBe(true); }); it('renders a starting run without a fabricated duration', () => { @@ -358,6 +358,27 @@ describe('describeBlockingBackgroundWork (#8741)', () => { 'Cannot clear.', ); expect(message).toContain('Use /workflows to inspect them, then retry.'); + expect(message).toContain('Retry once the run has finished starting.'); + }); + + it('keeps the starting-run hint when the starting row overflows', () => { + const shells = Array.from({ length: 10 }, (_, i) => ({ + shellId: `shell_${i}`, + status: 'running', + command: `cmd ${i}`, + startTime: now - (10 - i) * 1_000, + })); + const message = buildBackgroundWorkBlockedMessage( + createEnumeratingMockConfig({ + shells, + startingWorkflows: ['wf_starting'], + }), + 'Cannot clear.', + ); + + expect(message).not.toContain('[wf_starting]'); + expect(message).toContain(' …and 1 more'); + expect(message).toContain('Retry once the run has finished starting.'); }); it('sorts lines by start time', () => { diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.ts index 35d0667a195..d1376a98cb3 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.ts @@ -65,13 +65,11 @@ export interface BlockingBackgroundWork { /** True when at least one blocking entry is an agent, shell, or monitor * (the kinds `/tasks` lists). */ hasTaskEntries: boolean; - /** True when at least one blocking entry is a workflow run (listed via - * `/workflows`, not `/tasks`). */ - hasWorkflowRuns: boolean; /** True when at least one blocking workflow run is actually inspectable on * `/workflows`. A reserved-but-unregistered run is not: that command reads * `registry.list()`, which the reservation has not entered yet. */ hasInspectableWorkflowRuns: boolean; + hasStartingWorkflowRuns: boolean; } /** @@ -178,10 +176,10 @@ export function describeBlockingBackgroundWork( return { lines, hasTaskEntries: entries.some((entry) => !entry.isWorkflowRun), - hasWorkflowRuns: entries.some((entry) => entry.isWorkflowRun), hasInspectableWorkflowRuns: entries.some( (entry) => entry.isWorkflowRun && !entry.isStarting, ), + hasStartingWorkflowRuns: entries.some((entry) => entry.isStarting), }; } @@ -206,11 +204,9 @@ export function buildBackgroundWorkBlockedMessage( return [ baseMessage, ...blocking.lines, - // A reservation is on no surface yet — `/workflows` reads - // `registry.list()`, which it has not entered — so pointing there would - // send the user to a list that cannot show what is blocking them. - surfaces - ? `Use ${surfaces} to inspect them, then retry.` - : 'Retry once the run has finished starting.', + ...(surfaces ? [`Use ${surfaces} to inspect them, then retry.`] : []), + ...(blocking.hasStartingWorkflowRuns + ? ['Retry once the run has finished starting.'] + : []), ].join('\n'); } From 26abdbeef0dea40c18ad5cc93e7940d4ae98a2f2 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:20:30 +0800 Subject: [PATCH 21/23] test(cli): pin background work hint guards --- packages/cli/src/ui/utils/backgroundWorkUtils.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts index 1144482ea93..6f75de1e22f 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts @@ -284,6 +284,7 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(joined).not.toContain('shell_2'); expect(joined).not.toContain('wf_3'); expect(summary?.hasTaskEntries).toBe(true); + expect(summary?.hasInspectableWorkflowRuns).toBe(true); expect(summary?.hasStartingWorkflowRuns).toBe(false); }); @@ -338,7 +339,7 @@ describe('describeBlockingBackgroundWork (#8741)', () => { expect(message).toContain(' [wf_starting] (starting)'); // `/workflows` reads registry.list(), which a reservation has not // entered — pointing there would name a list that cannot show it. - expect(message).not.toContain('/workflows'); + expect(message).not.toContain('Use '); expect(message).toContain('Retry once the run has finished starting.'); }); @@ -494,6 +495,7 @@ describe('buildBackgroundWorkBlockedMessage (#8741)', () => { expect(message.startsWith(base)).toBe(true); expect(message).toContain('[shell_1]'); expect(message).toContain('Use /tasks to inspect them, then retry.'); + expect(message).not.toContain('Retry once the run has finished starting.'); }); it('points at /workflows when only workflow runs block', () => { From ac988b892eaf0c4db12ae7c46b7fc6a0ed893091 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:10:35 +0800 Subject: [PATCH 22/23] fix(serve): redact untrusted standalone workflow replay --- .../serve/routes/standalone-sessions.test.ts | 49 ++++++++++++++++++- .../src/serve/routes/standalone-sessions.ts | 11 ++++- packages/cli/src/serve/server.ts | 1 + 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/routes/standalone-sessions.test.ts b/packages/cli/src/serve/routes/standalone-sessions.test.ts index d75ef8cb6b5..bec45e42f57 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.test.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.test.ts @@ -20,7 +20,11 @@ import { registerStandaloneSessionRoutes } from './standalone-sessions.js'; const sessionId = '11111111-1111-4111-8111-111111111111'; -function createHarness() { +function createHarness({ + isWorkspaceTrusted = () => true, +}: { + isWorkspaceTrusted?: () => boolean; +} = {}) { const service = { create: vi.fn(async () => ({ session: { @@ -90,6 +94,7 @@ function createHarness() { service: service as unknown as StandaloneSessionService, mutate, sendBridgeError, + isWorkspaceTrusted, }); return { app, service, mutate }; } @@ -424,6 +429,48 @@ describe('standalone session routes', () => { expect(JSON.stringify(response.body)).not.toContain('full skill body'); }); + it.each(['load', 'resume'] as const)( + 'redacts workflows from untrusted standalone %s replay arrays', + async (action) => { + const { app, service } = createHarness({ + isWorkspaceTrusted: () => false, + }); + const commandsEvent = { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + }, + }; + service[action].mockResolvedValueOnce({ + sessionId, + compactedReplay: [commandsEvent], + liveJournal: [commandsEvent], + } as never); + + const response = await request(app) + .post(`/standalone/sessions/${sessionId}/${action}`) + .send({}); + + expect(response.status).toBe(200); + for (const replayKey of ['compactedReplay', 'liveJournal'] as const) { + expect( + response.body[replayKey][0].data.update.availableCommands.map( + (command: { name: string }) => command.name, + ), + ).toEqual(['help']); + } + }, + ); + it('repairs an exact directory only with an empty object body', async () => { const { app, service } = createHarness(); diff --git a/packages/cli/src/serve/routes/standalone-sessions.ts b/packages/cli/src/serve/routes/standalone-sessions.ts index 283830a011c..3050e3e1d91 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.ts @@ -19,6 +19,7 @@ import type { StandaloneSessionService, } from '../conversations/standalone-session-service.js'; import { omitSkillDetailsFromReplayArrays } from '../skill-details-redaction.js'; +import { redactWorkflowsFromReplayArrays } from '../workflow-session-gate.js'; import type { SendBridgeError } from '../server/error-response.js'; import { InvalidCursorError } from '../server/session-list.js'; import { @@ -33,6 +34,7 @@ export interface RegisterStandaloneSessionRoutesDeps { service: StandaloneSessionService; mutate: (options?: { strict?: boolean }) => RequestHandler; sendBridgeError: SendBridgeError; + isWorkspaceTrusted: () => boolean; } function sendInvalidRequest(res: Response, message: string): void { @@ -426,7 +428,14 @@ export function registerStandaloneSessionRoutes( await cleanupRestore()?.catch(() => undefined); return; } - res.status(200).json(omitSkillDetailsFromReplayArrays(restored)); + const shaped = omitSkillDetailsFromReplayArrays(restored); + res + .status(200) + .json( + deps.isWorkspaceTrusted() + ? shaped + : redactWorkflowsFromReplayArrays(shaped), + ); }, ), ); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index a081280787f..2fa565d98be 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -2651,6 +2651,7 @@ export function createServeApp( service: standaloneSessionService, mutate, sendBridgeError, + isWorkspaceTrusted: isPrimaryWorkspaceTrusted, }); standaloneSessionsAvailable = true; } From dce027727830cf823ffb2f684ef7754fcfb0b203 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:10:43 +0800 Subject: [PATCH 23/23] test(web-shell): fix daemon SDK mock path --- packages/web-shell/client/main-boot.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/main-boot.test.tsx b/packages/web-shell/client/main-boot.test.tsx index 1e0c4c11bb9..0a8c16616de 100644 --- a/packages/web-shell/client/main-boot.test.tsx +++ b/packages/web-shell/client/main-boot.test.tsx @@ -22,7 +22,7 @@ vi.mock('react-dom/client', async (importOriginal) => ({ }, }, })); -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ DaemonWorkspaceProvider: ({ children }: { children: ReactNode }) => children, })); vi.mock('./components/WorkspaceSessionProvider', () => ({