diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 511f17d9c58..3fad95e7067 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -3202,7 +3202,9 @@ describe('createAcpSessionBridge', () => { availableSkills: [], }); await expect( - bridge.getSessionTasksStatus(session.sessionId), + bridge.getSessionTasksStatus(session.sessionId, { + includeWorkflows: true, + }), ).resolves.toMatchObject({ sessionId: session.sessionId, tasks: [], @@ -3226,6 +3228,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(); }); @@ -24075,6 +24081,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 05f2761ca5b..be2fddb7379 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -80,6 +80,7 @@ import { type ServeSessionContextStatus, type ServeSessionLspStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, type ServeWorkspaceMcpResourcesStatus, type ServeWorkspaceMcpStatus, type ServeWorkspaceMcpToolsStatus, @@ -11416,10 +11417,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 }, ); }, @@ -11434,7 +11436,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, @@ -11442,6 +11447,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 controlSessionGoal(sessionId, request, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 87c46797406..d5b50fe1023 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -46,6 +46,7 @@ import type { ServeSessionLspStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, + ServeSessionWorkflowTaskStatus, ServeWorkspaceExtensionsStatus, ServeWorkspaceHooksStatus, ServeWorkspaceMcpToolsStatus, @@ -316,11 +317,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. */ @@ -331,6 +336,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [ 'agent', 'notification', 'shell', + 'workflow', ]; export interface ActiveWorkHeartbeatCapabilityV1 { @@ -1784,7 +1790,10 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { ): 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; @@ -1802,9 +1811,28 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { 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 81990d54307..a950ffcd017 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -176,6 +176,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', sessionGoalControl: 'qwen/control/session/goal/control', sessionGoalClear: 'qwen/control/session/goal/clear', /** @@ -617,6 +618,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 { @@ -728,10 +736,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 bec09694af3..3b5354ca3c8 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -100,6 +100,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(), })); @@ -293,6 +299,17 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ await importOriginal() ).toolResultPartDiagnosticValues, preloadContentGenerator: mockPreloadContentGenerator, + isTerminalWorkflowStatus: ( + await importOriginal() + ).isTerminalWorkflowStatus, + getWorkflowTaskMutationKey: ( + await importOriginal() + ).getWorkflowTaskMutationKey, + tryWithWorkflowTaskMutation: ( + await importOriginal() + ).tryWithWorkflowTaskMutation, + listSavedWorkflows: mockListSavedWorkflows, + listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, extractDaemonTraceContext: mockExtractDaemonTraceContext, withDaemonSpan: mockWithDaemonSpan, @@ -2096,6 +2113,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { dispose: ReturnType; prompt: ReturnType; releaseTodoStopGuardQueuedPromptWait: ReturnType; + refreshWorkflowHistory: ReturnType; + deleteWorkflowHistory: ReturnType; + noteExternalWorkflowDeletion: ReturnType; isIdle: ReturnType; isTurnIdle: ReturnType; getCreatedAt: ReturnType; @@ -4178,6 +4198,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'), @@ -4219,6 +4240,11 @@ 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), + setWorkflowsEnabled: vi.fn(), + getBareMode: vi.fn().mockReturnValue(false), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), isTrustedFolder: vi.fn().mockReturnValue(true), }; } @@ -4456,61 +4482,83 @@ 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), - shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), - 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), - buildAvailableCommandsSnapshot: vi.fn(() => - buildAvailableCommandsSnapshot(createdConfig), - ), - installManagedConversationActivation: vi.fn(), - installPendingManagedConversationBinding: vi.fn(), - commitManagedConversationBinding: vi.fn().mockResolvedValue(undefined), - releaseManagedConversationBinding: vi.fn().mockResolvedValue(undefined), - appendLiveConversationTranscript: vi.fn().mockResolvedValue(undefined), - collectActiveWorkHolds: vi.fn().mockReturnValue([]), - hasStandaloneRelocationBlockers: vi.fn().mockReturnValue(false), - 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), - getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000), - getTurnCount: vi.fn().mockReturnValue(3), - 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), + shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), + getConfig: vi.fn().mockReturnValue(createdConfig), + getWorkflowHistory: vi.fn().mockReturnValue(workflowHistory), + refreshWorkflowHistory: vi.fn(() => + mockListWorkflowSnapshots(createdConfig), + ), + deleteWorkflowHistory: vi.fn().mockResolvedValue(false), + noteExternalWorkflowDeletion: vi.fn(), + 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), + buildAvailableCommandsSnapshot: vi.fn(() => + buildAvailableCommandsSnapshot(createdConfig), + ), + installManagedConversationActivation: vi.fn(), + installPendingManagedConversationBinding: vi.fn(), + commitManagedConversationBinding: vi + .fn() + .mockResolvedValue(undefined), + releaseManagedConversationBinding: vi + .fn() + .mockResolvedValue(undefined), + appendLiveConversationTranscript: vi + .fn() + .mockResolvedValue(undefined), + collectActiveWorkHolds: vi.fn().mockReturnValue([]), + hasStandaloneRelocationBlockers: vi.fn().mockReturnValue(false), + 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), + getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000), + getTurnCount: vi.fn().mockReturnValue(3), + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + }; + lastSessionMock = sessionMock; + return sessionMock as unknown as InstanceType; + }, + ); return innerConfig; } @@ -9199,6 +9247,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([ { @@ -9299,6 +9350,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, @@ -9359,6 +9423,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, @@ -9461,6 +9530,87 @@ 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); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + 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('aggregates subagent token usage into session stats sources', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; await setupSessionMocks(sessionId); @@ -11575,52 +11725,1196 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockConfig, makeSessionSettings(), mockArgv, - { - privateParentCapability: 'expected-capability', - externalToolGuardRequired: true, - }, + { + privateParentCapability: 'expected-capability', + externalToolGuardRequired: true, + }, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + extMethod: vi.fn(), + get closed() { + return mockConnectionState.promise; + }, + } as unknown as AgentSideConnectionLike) as AgentLike; + await agent.initialize({ + clientCapabilities: {}, + _meta: { + 'qwen-code/private-parent-capability': 'expected-capability', + }, + }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, { + sessionId, + directive: 'review this branch', + }), + ).resolves.toMatchObject({ launched: true }); + expect(build).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('allows cancelling paused agent tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const cancel = vi.fn(); + const abandon = vi.fn(); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'agent-1', + kind: 'agent', + status: 'paused', + }), + cancel, + abandon, + }), + }); + + 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: 'agent-1', + taskKind: 'agent', + }), + ).resolves.toEqual({ cancelled: true, status: 'paused' }); + expect(abandon).toHaveBeenCalledWith('agent-1'); + expect(cancel).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rejects sessionTaskCancel with invalid params', async () => { + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId: 'session-1', + taskId: 'task-1', + taskKind: 'invalid', + }), + ).rejects.toThrow( + 'taskKind must be "agent", "shell", "monitor", or "workflow"', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels running shell tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const requestCancel = vi.fn(); + Object.assign(innerConfig, { + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'shell-1', + kind: 'shell', + status: 'running', + }), + requestCancel, + }), + }); + + 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: 'shell-1', + taskKind: 'shell', + }), + ).resolves.toEqual({ cancelled: true, status: 'running' }); + expect(requestCancel).toHaveBeenCalledWith('shell-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels running monitor tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const cancel = vi.fn(); + Object.assign(innerConfig, { + getMonitorRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'monitor-1', + kind: 'monitor', + status: 'running', + }), + 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: 'monitor-1', + taskKind: 'monitor', + }), + ).resolves.toEqual({ cancelled: true, status: 'running' }); + expect(cancel).toHaveBeenCalledWith('monitor-1'); + + mockConnectionState.resolve(); + 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('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('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], + ['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, { + 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({ 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_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + includeWorkflows: true, + }), + ).resolves.toMatchObject({ sessionId, tasks: [] }); + 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(lastSessionMock!.refreshWorkflowHistory).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 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' ? { buildSessionOwnedBackground } : 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(buildSessionOwnedBackground).toHaveBeenCalledWith({ + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + 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', workflowRunId: task.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: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_1234abcd', + action: 'retry', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + resumeFromRunId: task.runId, + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + 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) => { + 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', + workflowRunId: action === 'rerun' ? rerunTask.runId : task.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('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); + 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 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: task.runId, + action: 'rerun', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: rerun.runId, + }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + }); + 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('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('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!({ - extMethod: vi.fn(), get closed() { return mockConnectionState.promise; }, - } as unknown as AgentSideConnectionLike) as AgentLike; - await agent.initialize({ - clientCapabilities: {}, - _meta: { - 'qwen-code/private-parent-capability': 'expected-capability', - }, - }); + }) as AgentLike; await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, { + 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, - directive: 'review this branch', - }), - ).resolves.toMatchObject({ launched: true }); - expect(build).toHaveBeenCalled(); + }); + }; + 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('allows cancelling paused agent tasks', async () => { - const sessionId = '11111111-1111-1111-1111-111111111111'; - const innerConfig = await setupSessionMocks(sessionId); - const cancel = vi.fn(); - const abandon = vi.fn(); - Object.assign(innerConfig, { - getBackgroundTaskRegistry: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ - id: 'agent-1', - kind: 'agent', - status: 'paused', - }), - cancel, - abandon, + 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), }), }); @@ -11630,67 +12924,178 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 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: [] }); - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { - sessionId, - taskId: 'agent-1', - taskKind: 'agent', - }), - ).resolves.toEqual({ cancelled: true, status: 'paused' }); - expect(abandon).toHaveBeenCalledWith('agent-1'); - expect(cancel).not.toHaveBeenCalled(); + + 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('rejects sessionTaskCancel with invalid params', async () => { + 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!; - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { - sessionId: 'session-1', - taskId: 'task-1', - taskKind: 'invalid', + // 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', workflowRunId: runId }; + }); + 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, + ), }), - ).rejects.toThrow('taskKind must be "agent", "shell", or "monitor"'); + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - mockConnectionState.resolve(); - await agentPromise; - }); + const retry = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId: sessionIdB, taskId: runId, action: 'retry' }, + ); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); - it('cancels running shell tasks', async () => { - const sessionId = '11111111-1111-1111-1111-111111111111'; - const innerConfig = await setupSessionMocks(sessionId); - const requestCancel = vi.fn(); - Object.assign(innerConfig, { - getBackgroundShellRegistry: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ - id: 'shell-1', - kind: 'shell', - status: 'running', - }), - requestCancel, + // 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('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( @@ -11699,40 +13104,90 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 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: 'shell-1', - taskKind: 'shell', + + 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), }), - ).resolves.toEqual({ cancelled: true, status: 'running' }); - expect(requestCancel).toHaveBeenCalledWith('shell-1'); + 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('cancels running monitor tasks', async () => { - const sessionId = '11111111-1111-1111-1111-111111111111'; - const innerConfig = await setupSessionMocks(sessionId); - const cancel = vi.fn(); - Object.assign(innerConfig, { - getMonitorRegistry: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ - id: 'monitor-1', - kind: 'monitor', - status: 'running', - }), - cancel, - }), + 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( @@ -11741,7 +13196,6 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockArgv, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); - const agent = capturedAgentFactory!({ get closed() { return mockConnectionState.promise; @@ -11749,14 +13203,66 @@ 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. + 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.sessionTaskCancel, { - sessionId, - taskId: 'monitor-1', - taskKind: 'monitor', + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdB, + taskId: 'wf_shared', + action: 'delete-history', }), - ).resolves.toEqual({ cancelled: true, status: 'running' }); - expect(cancel).toHaveBeenCalledWith('monitor-1'); + ).resolves.toEqual({ changed: true }); + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledWith( + '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; @@ -23615,6 +25121,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, }; } @@ -24215,6 +25723,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 ead481e186d..f0cbf29c307 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -134,6 +134,14 @@ import { type WorkspaceRememberContextMode, type ChatRecord, type ToolInvocationGuard, + type WorkflowParams, + type WorkflowToolResult, + type WorkflowRunRegistry, + getWorkflowTaskMutationKey, + isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, + listSavedWorkflows, + listWorkflowSnapshots, type TurnResultRecordPayload, sessionIdContext, } from '@qwen-code/qwen-code-core'; @@ -437,6 +445,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'; @@ -3375,6 +3402,13 @@ class QwenAgent implements Agent { private modelProviderReloadRevision = 0; 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(); private activePromptCalls = new Map>(); private workspaceMcpDiscoveryConfig: Config | undefined; private workspaceMcpDiscoveryPromise: Promise | undefined; @@ -4016,6 +4050,25 @@ 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 { + // 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); + } + } 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(); @@ -7581,19 +7634,49 @@ class QwenAgent implements Agent { sessionId: string, ): Promise { const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); const { availableCommands, availableSkills } = await session.buildAvailableCommandsSnapshot(); + 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 { @@ -8394,10 +8477,12 @@ class QwenAgent implements Agent { 'Invalid or missing sessionId', ); } - return this.buildSessionTasksStatus(sessionId) as unknown as Record< - string, - unknown - >; + const session = this.sessionOrThrow(sessionId); + return (await this.buildSessionTasksStatus( + sessionId, + params['includeWorkflows'] === true && + this.canUseWorkflowControls(session.getConfig()), + )) as unknown as Record; } case SERVE_STATUS_EXT_METHODS.sessionLspStatus: { const sessionId = params['sessionId']; @@ -10860,11 +10945,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( @@ -10925,12 +11011,249 @@ 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); + // 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. + // 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`, + ); + return { cancelled: true, status: 'cancelled' }; + } + 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 }; + } + const mutationClaim = + action === 'run-saved' + ? getWorkflowTaskMutationKey(config, taskId, 'saved') + : getWorkflowTaskMutationKey(config, taskId); + if (action === 'delete-history') { + 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 — 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 }; + }, + ); + if (!attempt.acquired) { + return { changed: false }; + } + return attempt.value; + } + const registry = config.getWorkflowRunRegistry(); + if (action === 'run-saved') { + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + 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 }; + }, + ); + if (!attempt.acquired) { + return { changed: false }; + } + return attempt.value; + } + 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) && + !liveElsewhere + : task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled'; + if (!canStart || !task.script) { + return { changed: false, status: task.status }; + } + 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.`, + ); + } + 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 }; + } + // `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) { + return { changed: false, status: task.status }; + } + return attempt.value; + } + 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) { @@ -12233,6 +12556,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 && @@ -12891,6 +13238,89 @@ 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. + * + * 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, + runId: string, + ): boolean { + for (const [sessionId, session] of this.sessions) { + if (sessionId === excludeSessionId) continue; + 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 { + if (registry.isStarting?.(runId)) return true; + 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, @@ -12938,6 +13368,7 @@ class QwenAgent implements Agent { { errorKind: 'session_id_conflict', sessionId }, ); } + const workflowHistory = await listWorkflowSnapshots(config); const session = new Session( sessionId, config, @@ -12945,6 +13376,8 @@ class QwenAgent implements Agent { settings, (operation) => this.runExclusiveHistoryMutation(sessionId, operation), () => this.activeWorkReporter?.notifyChanged(), + workflowHistory, + (runId) => this.isWorkflowRunLiveOutsideSession(sessionId, runId), ); const replaySessionHistory = async () => { if ( diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 684f13bc823..7727acf2f1f 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -215,6 +215,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ 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 93f75bf7822..af1fe3c29b8 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 @@ -168,6 +168,13 @@ 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(), + setSnapshotPersistedCallback: 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 3d3768f326f..e2b92b2840f 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -77,6 +77,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(), @@ -154,6 +156,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 { @@ -560,8 +564,19 @@ describe('Session', () => { getFunctionDeclarationsFiltered: ReturnType; }; let mockWorkflowRunRegistry: { + setCompletionCallback: ReturnType; + setStatusChangeCallback: ReturnType; + setSnapshotPersistedCallback: ReturnType; + clearStatusChangeCallback: ReturnType; setApprovalRequestCallback: ReturnType; resolvePendingApproval: ReturnType; + get: ReturnType; + getHandle: ReturnType; + isStarting: ReturnType; + listStartingRunIds: ReturnType; + removeTerminal: ReturnType; + list: ReturnType; + abortAll: ReturnType; }; let mockGoalRuntime: { getSnapshot: ReturnType; @@ -676,6 +691,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; @@ -785,8 +804,19 @@ describe('Session', () => { ), }; mockWorkflowRunRegistry = { + setCompletionCallback: vi.fn(), + setStatusChangeCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), resolvePendingApproval: vi.fn().mockResolvedValue(true), + 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(), }; mockChatRecordingService = { @@ -1142,7 +1172,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) @@ -1242,6 +1274,54 @@ describe('Session', () => { session.dispose(); }); + 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(); + + // 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([ + { runId: 'wf-paused', status: 'paused' }, + ]); + expect(session.collectActiveWorkHolds()).toEqual([]); + expect(session.isIdle()).toBe(true); + 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 = @@ -1326,6 +1406,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( @@ -2746,6 +2857,1017 @@ 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 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('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('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 = { + 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('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('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', + }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); + 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 = { + 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('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(); + 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('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('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' }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); + + 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' }); + + 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() @@ -32319,6 +33441,26 @@ describe('Session', () => { expect( mockBackgroundShellRegistry.setNotificationCallback, ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.setCompletionCallback, + ).toHaveBeenLastCalledWith(undefined); + 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', () => { @@ -36146,6 +37288,77 @@ describe('Session', () => { internals.notificationProcessing = false; }); + it('classifies workflow notifications from the captured baseline', () => { + 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; + 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', + }); + currentWorkflow = { + runId: 'baseline-workflow', + status: 'running', + }; + callback('retry result', '', { + runId: 'baseline-workflow', + status: 'completed', + }); + currentWorkflow = undefined; + callback('new result', '', { + runId: 'new-workflow', + status: 'completed', + }); + + expect(internals.notificationQueue).toEqual([ + expect.objectContaining({ + taskId: 'baseline-workflow', + continuesTodoStopGuardWorkChain: false, + }), + expect.objectContaining({ + taskId: 'baseline-workflow', + continuesTodoStopGuardWorkChain: true, + }), + 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 d6828289bd4..c65b8d7dd7c 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -53,6 +53,8 @@ import type { ChatRecordingService, TurnResultRecordPayload, WorkflowApproval, + WorkflowSnapshot, + WorkflowTask, BranchPoint, } from '@qwen-code/qwen-code-core'; import { @@ -202,6 +204,13 @@ import { toolResultPartDiagnosticValues, getInvocationContext, runWithInvocationContext, + getWorkflowTaskMutationKey, + isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, + MAX_RETAINED_SNAPSHOTS, + toSnapshot, + deleteWorkflowSnapshot, + listWorkflowSnapshots, truncateNotificationLabel, buildBackgroundEntryLabel, collectSessionTurnState, @@ -528,6 +537,7 @@ type TodoStopGuardBackgroundBaseline = { agents: Set; shells: Set; monitors: Set; + workflows: Set; wakeups: Set; }; @@ -1389,7 +1399,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. */ @@ -1981,13 +1991,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; @@ -2015,6 +2026,36 @@ 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[]; + /** + * 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 + >(); + /** + * 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?: { @@ -2087,8 +2128,19 @@ export class Session implements SessionContext { * a full snapshot; the Session itself keeps no reporting state. */ 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]; this.requiresManagedConversationBinding = isReservedStandaloneSessionSourceType( this.config.getSessionSourceType?.(), @@ -2942,6 +2994,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', @@ -2967,6 +3020,7 @@ export class Session implements SessionContext { .filter((item) => item.kind === 'monitor') .map((item) => item.taskId), ]), + workflows: new Set(workflows), wakeups: new Set([ ...wakeups.map((job) => job.id), ...this.cronQueue.flatMap((item) => @@ -3069,6 +3123,17 @@ export class Session implements SessionContext { return true; } + const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? []; + if ( + workflows.some( + (task) => + !baseline.workflows.has(task) && + !isTerminalWorkflowStatus(task.status), + ) + ) { + return true; + } + if (!this.config.isCronEnabled?.()) return false; const wakeups = this.config.getCronScheduler?.()?.list?.() ?? []; return wakeups.some( @@ -3384,6 +3449,177 @@ export class Session implements SessionContext { return this.config; } + getWorkflowHistory(): readonly WorkflowSnapshot[] { + return this.workflowHistory; + } + + 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) { + // 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); + } + } + // 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); + this.#pruneUnpersistedWorkflowHistory(); + return this.workflowHistory; + } + + 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); + }; + if (!isDeletable()) return false; + const handle = registry.getHandle(runId); + if (handle) { + await handle.completion; + if (!isDeletable()) return false; + } + await this.refreshWorkflowHistory(); + if (!isDeletable()) return false; + // 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; + } + // 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); + this.unpersistedWorkflowHistory.delete(runId); + this.mergedWorkflowRunIds.delete(runId); + this.persistedWorkflowRunIds.delete(runId); + this.workflowHistory = this.workflowHistory.filter( + (item) => item.runId !== runId, + ); + this.#activeWorkChanged(); + 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 + // (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. 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); + 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); + } + } + } + reloadModelProvidersFromDisk(): void { if ( !this.settings.reloadScopesFromDiskAtomically([ @@ -3640,14 +3876,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 }); } @@ -3658,6 +3899,26 @@ export class Session implements SessionContext { if (shellActive) { holds.push({ category: 'shell', id: 'background-shells' }); } + 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. + if (task.status === 'running' || task.status === 'pausing') { + holds.push({ category: 'workflow', id: task.runId }); + } + } return holds; } @@ -3851,6 +4112,24 @@ 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() + .setSnapshotPersistedCallback(undefined); + if (this.#workflowStatusChangeCallback) { + this.config + .getWorkflowRunRegistry() + .clearStatusChangeCallback(this.#workflowStatusChangeCallback); + this.#workflowStatusChangeCallback = undefined; + } this.config.getChatRecordingService()?.setTitleRecordedCallback(undefined); this.unsubscribeChatRecordingFailure?.(); this.unsubscribeChatRecordingFailure = undefined; @@ -8955,6 +9234,36 @@ 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.setSnapshotPersistedCallback((runId) => { + // The run is safely on disk now; drop the unpersisted copy so a + // 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) => { + const entry = workflowRegistry.get(meta.runId); + this.#enqueueBackgroundNotification({ + displayText, + modelText, + taskId: meta.runId, + status: meta.status, + kind: 'workflow', + continuesTodoStopGuardWorkChain: + !entry || !this.todoStopGuardBackgroundBaseline.workflows.has(entry), + 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 @@ -9042,8 +9351,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 { @@ -9053,8 +9362,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(); } } @@ -9178,6 +9487,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 { @@ -9196,6 +9507,7 @@ export class Session implements SessionContext { ); } finally { this.currentAgentNotificationTaskId = null; + this.currentWorkflowNotificationTaskId = null; this.currentShellNotificationActive = false; this.#activeWorkChanged(); } 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 9176cfda54c..4f55f5c9707 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -188,6 +188,13 @@ describe('Session.pendingWorktreeNotice', () => { clearStatusChangeCallback: vi.fn(), hasRunningEntries: vi.fn().mockReturnValue(false), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setSnapshotPersistedCallback: 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 1e40bc70408..2d9047b95b5 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -108,6 +108,10 @@ import { setupGithubEventData, } from '../routes/workspace-setup-github.js'; import { parseWorkspaceVoiceUpdateParams } from '../routes/workspace-voice.js'; +import { + redactWorkflowsFromAvailableCommandsEvent, + redactWorkflowsFromSupportedCommands, +} from '../workflow-session-gate.js'; import { MAX_TRUST_REASON_LENGTH } from '../validation-limits.js'; import { publicErrorMessage, @@ -278,6 +282,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`, @@ -3010,10 +3016,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; } @@ -3744,11 +3754,112 @@ 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, { + // 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; } + 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; + } + if (kind === 'workflow' && !this.isWorkspaceTrusted()) { + this.replyConn(conn, id, { + cancelled: false, + reason: 'disabled', + }); + 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; + } + if (!this.isWorkspaceTrusted()) { + this.replyConn(conn, id, { changed: false }); + 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; @@ -5410,9 +5521,12 @@ export class AcpDispatcher { // `event.data` is the ACP `SessionNotification` (params shape). // `event.id` is the bus cursor → SSE `id:` line for `Last-Event-ID` // resume (the content frames §1.8 recovers all flow through here). + const shaped = this.isWorkspaceTrusted() + ? event + : redactWorkflowsFromAvailableCommandsEvent(event); conn.sendSession( sessionId, - notification('session/update', event.data), + notification('session/update', shaped.data), event.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 aadc6eaf505..cafdea4281a 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 @@ -549,9 +552,48 @@ 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: + | { + 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, @@ -7844,7 +7886,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId: 'sess-1', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: skillDetails, @@ -7865,7 +7910,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId: 'sess-1', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: skillDetails, @@ -8553,12 +8601,198 @@ 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 () => { + 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('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)); + + const sessionStream = await openStream(connId, 'sess-1'); + const sessionFrame = takeFrames(sessionStream, 1); + await new Promise((r) => setTimeout(r, 30)); + bridge.queues.get('sess-1')?.push({ + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'init', description: 'Initialize' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + }, + }); + + 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', + }, + }); + 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( + (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(byId.get(63)).toMatchObject({ result: { sessionId: 'sess-1', tasks: [] }, }); + await expect(sessionFrame).resolves.toEqual([ + expect.objectContaining({ + method: 'session/update', + params: expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'init', description: 'Initialize' }], + }), + }), + }), + ]); + // 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(); }); it('_qwen/session/lsp returns status', async () => { diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 18e452fe35f..fe8cacaba06 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -142,7 +142,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<{ @@ -758,11 +758,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 { @@ -1132,6 +1148,7 @@ function makeHarness(opts?: { undefined, { workspaceRegistry: registry, + daemonEnv: {}, ...(opts?.daemonLog ? { daemonLog: opts.daemonLog } : {}), ...(opts?.liveConversationWorkspace ? { liveConversationWorkspace: opts.liveConversationWorkspace } @@ -1180,13 +1197,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..5f37e3d179f 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,23 @@ 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' || + runtime.env.workflowsEnabledBySettings === true + ); } export function registerCapabilitiesRoutes( @@ -103,6 +123,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 f0af68d339e..0ef5a43c98a 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -44,6 +44,7 @@ import { DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, type BridgeBranchedSession, } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { parseSessionSource } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, @@ -163,6 +164,11 @@ import { sendUntrustedWorkspaceResponse, sendWorkspaceRuntimeUnavailable, } from '../workspace-route-runtime.js'; +import { + redactWorkflowsFromAvailableCommandsEvent, + redactWorkflowsFromReplayArrays, + redactWorkflowsFromSupportedCommands, +} from '../workflow-session-gate.js'; import type { WorkspaceEntry, WorkspaceRegistry, @@ -192,6 +198,26 @@ import { // storage is case-folding on macOS/Windows. const GIT_RESERVED_BRANCH = 'HEAD'; +function redactSdkSurfaceEvent( + event: T, + workspaceTrusted: boolean, +): T { + const shaped = omitSkillDetailsForSdkSurface(event); + return workspaceTrusted + ? shaped + : redactWorkflowsFromAvailableCommandsEvent(shaped); +} + +function redactSdkSurfaceReplay< + T extends { + compactedReplay?: BridgeEvent[]; + liveJournal?: BridgeEvent[]; + }, +>(session: T, workspaceTrusted: boolean): T { + const shaped = omitSkillDetailsFromReplayArrays(session); + return workspaceTrusted ? shaped : redactWorkflowsFromReplayArrays(shaped); +} + // Byte-length caps for branch names. git creates loose refs as files under // `.git/refs/heads/`, so each `/`-separated component is bounded by the // filesystem's per-component name limit (255 bytes on Linux/macOS, minus the @@ -3404,7 +3430,9 @@ export function registerSessionRoutes( } // Same replay-array shape as the load response; redact skill // bodies for the browser surface (#9234). - res.status(200).json(omitSkillDetailsFromReplayArrays(session)); + res + .status(200) + .json(redactSdkSurfaceReplay(session, runtime.trusted)); } catch (err) { sendBridgeError(res, err, { route, sessionId }); } @@ -3522,7 +3550,9 @@ export function registerSessionRoutes( } return; } - res.status(200).json(omitSkillDetailsFromReplayArrays(session)); + res + .status(200) + .json(redactSdkSurfaceReplay(session, runtime.trusted)); return; } } catch (error) { @@ -3912,7 +3942,7 @@ export function registerSessionRoutes( } // The load response embeds the replay snapshot inline; redact the // skill bodies there just like the SSE egress does (#9234). - res.status(200).json(omitSkillDetailsFromReplayArrays(session)); + res.status(200).json(redactSdkSurfaceReplay(session, runtime.trusted)); } catch (err) { if (err instanceof RequestedSessionIdAdmissionError) { sendRequestedSessionIdAdmissionError(res, err, route); @@ -4111,7 +4141,10 @@ export function registerSessionRoutes( res .status(201) .json( - omitSkillDetailsFromReplayArrays(result as BridgeBranchedSession), + redactSdkSurfaceReplay( + result as BridgeBranchedSession, + runtime.trusted, + ), ); }, { rejectStandalone: true }, @@ -4181,7 +4214,7 @@ export function registerSessionRoutes( } return; } - res.status(201).json(omitSkillDetailsFromReplayArrays(result)); + res.status(201).json(redactSdkSurfaceReplay(result, runtime.trusted)); }, { rejectStandalone: true }, ), @@ -4334,6 +4367,7 @@ export function registerSessionRoutes( return; } + let workspaceTrusted = false; try { const result = await archiveCoordinator.runSharedMany( [sessionId], @@ -4345,6 +4379,7 @@ export function registerSessionRoutes( cursor !== undefined, ); if (!runtime) return undefined; + workspaceTrusted = runtime.trusted; captureRuntimeGenerationAssertion(runtime)?.(); return runtime.bridge.getSessionTranscriptPage({ sessionId, @@ -4360,7 +4395,9 @@ export function registerSessionRoutes( .set('Cache-Control', 'no-store') .json({ ...result, - events: (result.events ?? []).map(omitSkillDetailsForSdkSurface), + events: (result.events ?? []).map((event) => + redactSdkSurfaceEvent(event, workspaceTrusted), + ), }); } catch (err) { sendBridgeError(res, err, { @@ -4489,11 +4526,14 @@ export function registerSessionRoutes( v: 1 as const, sessionId, events: replay.updates.map((update) => - omitSkillDetailsForSdkSurface({ - v: 1 as const, - type: 'session_update' as const, - data: update, - }), + redactSdkSurfaceEvent( + { + v: 1 as const, + type: 'session_update' as const, + data: update, + }, + runtime.trusted, + ), ), ...(replay.nextCursor && !cursorTooLarge ? { nextCursor: replay.nextCursor } @@ -4638,10 +4678,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), ); }, ), @@ -4651,10 +4695,16 @@ 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, { + // 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', + }), + ); }, ), ); @@ -4807,16 +4857,80 @@ 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; } + if (kind === 'workflow' && !runtime.trusted) { + res.status(200).json({ cancelled: false, reason: 'disabled' }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; res .status(200) .json( - await runtime.bridge.cancelSessionTask(sessionId, taskId, kind), + 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; + } + if (!runtime.trusted) { + res.status(200).json({ changed: false }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await runtime.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + clientId !== undefined ? { clientId } : undefined, + ), ); }, ), diff --git a/packages/cli/src/serve/routes/sse-events.ts b/packages/cli/src/serve/routes/sse-events.ts index 888484ef921..7aec3622d81 100644 --- a/packages/cli/src/serve/routes/sse-events.ts +++ b/packages/cli/src/serve/routes/sse-events.ts @@ -35,6 +35,7 @@ import { } from '../server/request-helpers.js'; import { parseEventEpochHeader } from '../sse-last-event-id.js'; import { omitSkillDetailsForSdkSurface } from '../skill-details-redaction.js'; +import { redactWorkflowsFromAvailableCommandsEvent } from '../workflow-session-gate.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; import { isInternalWorkspaceRuntime } from '../workspace-runtime-visibility.js'; import { requireSessionRuntime } from './session-runtime.js'; @@ -128,8 +129,13 @@ interface RegisterSseEventsRoutesDeps { type OmitId = Omit; -function formatSseFrame(event: BridgeEvent | OmitId): string { - const shaped = omitSkillDetailsForSdkSurface(event); +function formatSseFrame( + event: BridgeEvent | OmitId, + workspaceTrusted = false, +): string { + const shaped = omitSkillDetailsForSdkSurface( + workspaceTrusted ? event : redactWorkflowsFromAvailableCommandsEvent(event), + ); // SSE format: id (optional), event (optional), data, blank line. // The `id:` line is intentionally omitted when `event.id` is absent — // terminal/synthetic frames (e.g. daemon-side `stream_error`) must not @@ -333,6 +339,7 @@ export function registerSseEventsRoutes( let iter: AsyncIterator | undefined; let busEpoch: string | undefined; + let workspaceTrusted = false; const abort = new AbortController(); try { const virtualKey = parseVirtualSubagentSessionId(sessionId); @@ -344,6 +351,7 @@ export function registerSseEventsRoutes( daemonLog, }); if (!runtime) return; + workspaceTrusted = runtime.trusted; const snapshot = req.query['snapshot'] === '1'; const openSubscription = async (): Promise< { iter: AsyncIterator; busEpoch?: string } | undefined @@ -981,7 +989,7 @@ export function registerSseEventsRoutes( const liveEvent = liveTimingEnabled; const serverTimestamp = next.value._meta?.['serverTimestamp']; const outcome = await writeWithBackpressure( - formatSseFrame(next.value), + formatSseFrame(next.value, workspaceTrusted), ); if (outcome === 'closed') break; eventFramesWriteSettled += 1; 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/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 8616d3ae93a..01315c00f04 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -86,6 +86,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'; import * as scheduledTaskKeepalive from './scheduled-task-keepalive.js'; const originalTestRuntimeDir = process.env['QWEN_RUNTIME_DIR']; @@ -7335,6 +7336,7 @@ describe('runQwenServe runtime startup failures', () => { () => ({ merged: { + tools: { workflowsEnabled: !runtimeMounted }, advanced: { runtimeOutputDir: runtimeMounted ? '.runtime-reloaded' @@ -7421,6 +7423,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', @@ -7437,6 +7440,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); reloadedRuntimeValue = 'hot-synced'; await expect( @@ -7696,15 +7700,18 @@ 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; let providerRuntimeMutation = false; let failEnvFileRead = false; vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation( @@ -7713,6 +7720,10 @@ describe('runQwenServe runtime startup failures', () => { const isSecondary = workspace === secondary; return { merged: { + tools: { + workflowsEnabled: + workspace === dynamic ? !dynamicReloaded : !runtimeMounted, + }, advanced: { runtimeOutputDir: isSecondary ? runtimeMounted @@ -7776,10 +7787,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(); }, ); @@ -7815,6 +7833,7 @@ describe('runQwenServe runtime startup failures', () => { pinnedRuntimeBaseDir, ); expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(env.workflowsEnabledBySettings).toBe(true); await expect( secondaryRuntime!.workspaceService.reload({ @@ -7832,6 +7851,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); providerRuntimeMutation = true; await expect( @@ -7840,7 +7871,7 @@ describe('runQwenServe runtime startup failures', () => { workspaceCwd: secondary, }), ).resolves.toEqual({ status: 'applied' }); - expect(reloadEnvironment).toHaveBeenCalledOnce(); + expect(reloadEnvironment).toHaveBeenCalledTimes(2); expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe( 'provider-reloaded', ); @@ -7852,7 +7883,7 @@ describe('runQwenServe runtime startup failures', () => { workspaceCwd: secondary, }), ).resolves.toMatchObject({ runtimeEnvironmentApplied: false }); - expect(reloadEnvironment).toHaveBeenCalledOnce(); + expect(reloadEnvironment).toHaveBeenCalledTimes(2); expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe( 'provider-reloaded', ); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 33458a75f4e..3cb96f1015f 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -4778,6 +4778,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -4785,6 +4786,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], @@ -5565,6 +5568,8 @@ async function runQwenServeImpl( }; } replaceRuntimeEffectiveEnv(refreshedRuntimeEnv.effectiveEnv); + primaryRuntimeEnv.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; delete primaryRuntimeEnv.fallbackReason; primaryRuntimeEnv.envFileReadFailed = refreshedRuntimeEnv.envFileReadFailed; @@ -5655,6 +5660,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -5692,6 +5698,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -5699,6 +5706,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], @@ -6141,6 +6150,8 @@ async function runQwenServeImpl( } try { secondaryEnv.replace(refreshedRuntimeEnv.effectiveEnv); + secondaryEnv.metadata.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; secondaryEnv.metadata.envFileReadFailed = refreshedRuntimeEnv.envFileReadFailed; secondaryEnv.metadata.envFileReadFailures.splice( @@ -6822,6 +6833,8 @@ async function runQwenServeImpl( } try { wsEnv.replace(refreshedRuntimeEnv.effectiveEnv); + wsEnv.metadata.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; wsEnv.metadata.envFileReadFailed = refreshedRuntimeEnv.envFileReadFailed; wsEnv.metadata.envFileReadFailures.splice( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a81201cfd34..d50c75a4763 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -934,14 +934,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 }>; @@ -1229,6 +1245,7 @@ interface FakeBridge extends AcpSessionBridge { sessionSupportedCommandsCalls: string[]; sessionStatsCalls: string[]; sessionTasksCalls: string[]; + sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined>; sessionLspCalls: string[]; sessionTranscriptCalls: Array< Parameters[0] @@ -1236,7 +1253,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[]; controlSessionGoalCalls: Array<{ @@ -1424,9 +1454,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 controlSessionGoalCalls: FakeBridge['controlSessionGoalCalls'] = []; const continueSessionCalls: string[] = []; @@ -1770,6 +1804,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 controlSessionGoalImpl = @@ -2038,9 +2075,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionSupportedCommandsCalls, sessionStatsCalls, sessionTasksCalls, + sessionTasksOptions, sessionLspCalls, sessionTranscriptCalls, cancelSessionTaskCalls, + controlSessionWorkflowTaskCalls, clearSessionGoalCalls, controlSessionGoalCalls, continueSessionCalls, @@ -2327,9 +2366,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); @@ -2339,9 +2379,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); @@ -4038,6 +4092,90 @@ 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 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', + primary: false, + bridge: fakeBridge(), + trusted: false, + }); + const app = createServeApp(baseOpts, undefined, { + bridge: primaryBridge, + workspaceRegistry: createWorkspaceRegistry([ + primary, + secondary, + settingsEnabled, + 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: 'settings-enabled-id', + workflowsEnabled: true, + }), + expect.objectContaining({ + id: 'untrusted-id', + workflowsEnabled: false, + }), + ]); + }); + it('advertises the scoped workspace-memory tags on the wire', async () => { // The three tags this PR adds are pinned against a real `/capabilities` // body only in integration-tests/cli/qwen-serve-routes.test.ts, which no @@ -4282,6 +4420,7 @@ describe('createServeApp', () => { workspaceRegistry: registry, createWorkspaceRuntime: vi.fn(), workspaceRegistrationStore: {} as unknown as WorkspaceRegistrationStore, + daemonEnv: {}, }); const before = await request(app) @@ -4302,6 +4441,7 @@ describe('createServeApp', () => { cwd: WS_BOUND, primary: true, trusted: true, + workflowsEnabled: false, }, ]); @@ -4345,6 +4485,7 @@ describe('createServeApp', () => { const app = createServeApp(baseOpts, undefined, { bridge: primaryBridge, workspaceRegistry: registry, + daemonEnv: {}, }); const response = await request(app) @@ -4362,6 +4503,7 @@ describe('createServeApp', () => { displayName: 'Conversations', primary: false, trusted: true, + workflowsEnabled: false, kind: 'live', }); expect(response.body.features).not.toContain('multi_workspace_sessions'); @@ -9399,7 +9541,7 @@ describe('createServeApp', () => { const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const contextRes = await request(app) @@ -9414,6 +9556,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}`); @@ -9427,12 +9572,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']); }); @@ -9682,12 +9833,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 }), }); @@ -9695,22 +9846,188 @@ describe('createServeApp', () => { const app = createServeApp( { ...tokenOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const res = await request(app) .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, primaryWorkspaceTrusted: true }, + ); + + 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' }, + ]); + }); + + 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' }); + 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({ + 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(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([]); + }); + it.each([ ['agent%3A8', 'agent:8'], ['agent%2F8', 'agent/8'], @@ -13736,7 +14053,10 @@ describe('createServeApp', () => { sessionId: 'persisted-replay', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -13768,7 +14088,10 @@ describe('createServeApp', () => { sessionId: 'persisted-replay', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -13789,11 +14112,26 @@ describe('createServeApp', () => { liveJournal: [journalCommandsEvent, textEvent], }), }); - const app = createServeApp(baseOpts, undefined, { bridge }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: true, + bridge, + }), + ]), + }, + ); const res = await request(app) .post('/session/persisted-replay/load') .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({}); + .send({ cwd: WS_BOUND }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ sessionId: 'persisted-replay' }); @@ -13885,6 +14223,61 @@ describe('createServeApp', () => { expect(JSON.stringify(res.body)).not.toContain('LEAK-CANARY-SKILL-BODY'); }); + it('redacts workflows from untrusted load response replay arrays', async () => { + const commandsEvent = { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId: 'untrusted-replay', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + }, + } satisfies BridgeEvent; + const bridge = fakeBridge({ + loadImpl: async (req) => ({ + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: 'client-load', + state: {}, + compactedReplay: [commandsEvent], + }), + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: false, + bridge, + }), + ]), + }, + ); + + const res = await request(app) + .post('/session/untrusted-replay/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.compactedReplay[0].data.update.availableCommands).toEqual( + [{ name: 'help', description: 'Help' }], + ); + expect(commandsEvent.data.update.availableCommands).toHaveLength(2); + }); + it('passes client identity headers through to load/resume bridge calls', async () => { for (const action of ['load', 'resume'] as const) { const bridge = fakeBridge(); @@ -25235,7 +25628,10 @@ describe('createServeApp', () => { type: 'session_update', data: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -25252,6 +25648,15 @@ describe('createServeApp', () => { const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { bridge, boundWorkspace: wsDir, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: wsDir, + primary: true, + trusted: true, + bridge, + }), + ]), }); const res = await request(app) @@ -25261,12 +25666,63 @@ describe('createServeApp', () => { expect(res.status).toBe(200); const event = res.body.events[0] as { data: Record }; expect(event.data['sessionUpdate']).toBe('available_commands_update'); + expect(event.data['availableCommands']).toEqual([ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ]); const meta = event.data['_meta'] as Record; expect(meta['availableSkills']).toEqual(['bugfix']); expect(meta).not.toHaveProperty('availableSkillDetails'); expect(JSON.stringify(res.body)).not.toContain('x'.repeat(64)); }); + it('redacts workflows from untrusted transcript events', async () => { + const sid = '55555555-bbbb-cccc-dddd-aaaaaaaaaaad'; + const commandsEvent = { + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + } satisfies Omit; + const bridge = fakeBridge({ + sessionTranscriptImpl: async (req) => ({ + v: 1, + sessionId: req.sessionId, + events: [commandsEvent], + hasMore: false, + }), + }); + await writeTranscriptSession(sid); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: wsDir, + primary: true, + trusted: false, + bridge, + }), + ]), + }); + + const res = await request(app) + .get(`/session/${sid}/transcript`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body.events[0].data.availableCommands).toEqual([ + { name: 'help', description: 'Help' }, + ]); + expect(commandsEvent.data.availableCommands).toHaveLength(2); + }); + it('forwards an exclusive persisted-record boundary', async () => { const sid = '55555555-bbbb-cccc-dddd-bbbbbbbbbbbb'; const bridge = fakeBridge({ @@ -31034,7 +31490,10 @@ describe('GET /session/:id/events (SSE)', () => { // entries and the skill name list. const sharedUpdate = { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -31050,6 +31509,13 @@ describe('GET /session/:id/events (SSE)', () => { }, }; const bridge = fakeBridge({ + summaryImpl: (sessionId) => ({ + sessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-08-26T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }), async *subscribeImpl() { yield { id: 1, @@ -31071,7 +31537,22 @@ describe('GET /session/:id/events (SSE)', () => { }; }, }); - const app = createServeApp(baseOpts, undefined, { bridge }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: true, + bridge, + }), + ]), + }, + ); const res = await request(app) .get('/session/sess-A/events') @@ -31112,6 +31593,7 @@ describe('GET /session/:id/events (SSE)', () => { expect(commandsUpdate['sessionUpdate']).toBe('available_commands_update'); expect(commandsUpdate['availableCommands']).toEqual([ { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, ]); const meta = commandsUpdate['_meta'] as Record; expect(meta['availableSkills']).toEqual(['bugfix']); @@ -31163,6 +31645,80 @@ describe('GET /session/:id/events (SSE)', () => { expect(payload.data!.update).not.toHaveProperty('_meta'); }); + it('redacts workflows from untrusted SSE frames', async () => { + const sharedUpdate = { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }; + const bridge = fakeBridge({ + summaryImpl: (sessionId) => ({ + sessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-08-26T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }), + async *subscribeImpl() { + yield { + id: 1, + v: 1, + type: 'session_update', + data: { sessionId: 'sess-A', update: sharedUpdate }, + }; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: false, + bridge, + }), + ]), + }, + ); + + const res = await request(app) + .get('/session/sess-A/events') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + const payload = res.text + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map( + (line) => + JSON.parse(line.slice('data: '.length)) as { + type?: string; + data?: { + update?: { + sessionUpdate?: string; + availableCommands?: unknown[]; + }; + }; + }, + ) + .find( + (candidate) => + candidate.type === 'session_update' && + candidate.data?.update?.sessionUpdate === 'available_commands_update', + ); + expect(payload).toBeDefined(); + expect(payload!.data!.update!.availableCommands).toEqual([ + { name: 'help', description: 'Help' }, + ]); + expect(sharedUpdate.availableCommands).toHaveLength(2); + }); + it('correlates the SSE response, daemon lifecycle log, and request span', async () => { const predecessor = '019535d9-3df7-7a61-8f6d-6f37c39c5f19'; const setAttribute = vi.fn(); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 69f654d11cf..4b8a7820f73 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -2172,6 +2172,7 @@ export function createServeApp( maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession, sessionRestoreTimeoutMs, languageCodes, + daemonEnv: daemonEnvAtBoot, }); if (liveVoiceSurfaceAvailable) { @@ -2727,6 +2728,7 @@ export function createServeApp( service: standaloneSessionService, mutate, sendBridgeError, + isWorkspaceTrusted: isPrimaryWorkspaceTrusted, }); standaloneSessionsAvailable = true; } diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index f04e17b2e91..c95e8c681bf 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(61); + expect(registered).toHaveLength(62); 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 3c351646d96..1418b271ac1 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 61 unique routes with the audited 59/2 attribution split', () => { + it('contains 62 unique routes with the audited 60/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(61); - expect(new Set(keys).size).toBe(61); + expect(keys).toHaveLength(62); + expect(new Set(keys).size).toBe(62); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(59); + ).toHaveLength(60); 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 a8765e1ee7e..f399923b240 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', diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index de902671bb2..18c982e3cef 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -442,6 +442,7 @@ export interface CapabilitiesEnvelope { displayName?: string; primary: boolean; trusted: boolean; + workflowsEnabled?: boolean; removable?: boolean; kind?: 'live'; }>; diff --git a/packages/cli/src/serve/workflow-session-gate.test.ts b/packages/cli/src/serve/workflow-session-gate.test.ts new file mode 100644 index 00000000000..58c12ac60bd --- /dev/null +++ b/packages/cli/src/serve/workflow-session-gate.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { + redactWorkflowsFromAvailableCommandsEvent, + redactWorkflowsFromReplayArrays, +} from './workflow-session-gate.js'; + +interface CommandsData { + sessionUpdate: string; + availableCommands: Array<{ name: string; description: string }>; +} + +interface WrappedEvent extends BridgeEvent { + data: { sessionId: string; update: CommandsData }; +} + +interface FlatEvent extends BridgeEvent { + data: CommandsData; +} + +function wrappedCommandsEvent(): WrappedEvent { + return { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'workflows', description: 'Run workflows' }, + { name: 'init', description: 'Initialize' }, + ], + }, + }, + }; +} + +function flatCommandsEvent(): FlatEvent { + return { + id: 2, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'workflows', description: 'Run workflows' }, + { name: 'init', description: 'Initialize' }, + ], + }, + }; +} + +describe('redactWorkflowsFromAvailableCommandsEvent', () => { + it('removes workflows from wrapped frames without mutating the source', () => { + const event = wrappedCommandsEvent(); + const shaped = redactWorkflowsFromAvailableCommandsEvent(event); + + expect(shaped.data.update.availableCommands).toEqual([ + { name: 'init', description: 'Initialize' }, + ]); + expect(event.data.update.availableCommands).toHaveLength(2); + }); + + it('removes workflows from flat persisted-transcript frames', () => { + const shaped = + redactWorkflowsFromAvailableCommandsEvent(flatCommandsEvent()); + + expect(shaped.data.availableCommands).toEqual([ + { name: 'init', description: 'Initialize' }, + ]); + }); + + it('passes through unrelated frames unchanged', () => { + const event: BridgeEvent = { + id: 3, + v: 1, + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi' }, + }, + }, + }; + + expect(redactWorkflowsFromAvailableCommandsEvent(event)).toBe(event); + }); + + it('passes through command frames without workflows unchanged', () => { + const event = wrappedCommandsEvent(); + event.data.update.availableCommands = [ + { name: 'init', description: 'Initialize' }, + ]; + + expect(redactWorkflowsFromAvailableCommandsEvent(event)).toBe(event); + }); +}); + +describe('redactWorkflowsFromReplayArrays', () => { + it('redacts both replay arrays without mutating them', () => { + const compactedEvent = wrappedCommandsEvent(); + const journalEvent = flatCommandsEvent(); + const session = { + sessionId: 'sess-1', + compactedReplay: [compactedEvent], + liveJournal: [journalEvent], + }; + const shaped = redactWorkflowsFromReplayArrays(session); + + expect( + (shaped.compactedReplay[0] as WrappedEvent).data.update.availableCommands, + ).toEqual([{ name: 'init', description: 'Initialize' }]); + expect((shaped.liveJournal[0] as FlatEvent).data.availableCommands).toEqual( + [{ name: 'init', description: 'Initialize' }], + ); + expect(compactedEvent.data.update.availableCommands).toHaveLength(2); + expect(journalEvent.data.availableCommands).toHaveLength(2); + }); + + it('returns its input unchanged when no replay arrays are present', () => { + const session = { sessionId: 'sess-1', compactedReplay: undefined }; + expect(redactWorkflowsFromReplayArrays(session)).toBe(session); + }); +}); 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..babccffae9a --- /dev/null +++ b/packages/cli/src/serve/workflow-session-gate.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +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: [], + }; +} + +export function redactWorkflowsFromAvailableCommandsEvent< + T extends { type: string; data: unknown }, +>(event: T): T { + if (event.type !== 'session_update') return event; + const data = asRecord(event.data); + if (!data) return event; + const wrapped = asRecord(data['update']); + const flat = !wrapped && data['sessionUpdate'] !== undefined; + const candidate = flat ? data : wrapped; + if ( + !candidate || + candidate['sessionUpdate'] !== 'available_commands_update' + ) { + return event; + } + const availableCommands = candidate['availableCommands']; + if (!Array.isArray(availableCommands)) return event; + const filteredCommands = availableCommands.filter( + (command) => asRecord(command)?.['name'] !== 'workflows', + ); + if (filteredCommands.length === availableCommands.length) return event; + const nextCandidate = { + ...candidate, + availableCommands: filteredCommands, + }; + if (flat) return { ...event, data: nextCandidate }; + return { ...event, data: { ...data, update: nextCandidate } }; +} + +export function redactWorkflowsFromReplayArrays< + T extends { + compactedReplay?: BridgeEvent[]; + liveJournal?: BridgeEvent[]; + }, +>(session: T): T { + if (!session.compactedReplay && !session.liveJournal) return session; + return { + ...session, + ...(session.compactedReplay + ? { + compactedReplay: session.compactedReplay.map( + redactWorkflowsFromAvailableCommandsEvent, + ), + } + : {}), + ...(session.liveJournal + ? { + liveJournal: session.liveJournal.map( + redactWorkflowsFromAvailableCommandsEvent, + ), + } + : {}), + }; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index 92de6e20ef9..c3998e73dd7 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<{ diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 4ddc7ac5dde..c4e741835c6 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,7 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -501,6 +506,7 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -570,6 +576,7 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ hasRunningEntries: vi.fn().mockReturnValue(false), list: vi.fn().mockReturnValue([]), + listStartingRunIds: vi.fn().mockReturnValue([]), reset: vi.fn(), abortAll: vi.fn(), }), @@ -640,6 +647,7 @@ describe('clearCommand', () => { getWorkflowRunRegistry: vi.fn().mockReturnValue({ 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/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 0b5788754d0..6f75de1e22f 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; } @@ -231,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', () => { @@ -282,7 +284,102 @@ 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?.hasInspectableWorkflowRuns).toBe(true); + expect(summary?.hasStartingWorkflowRuns).toBe(false); + }); + + it('lists workflow runs that are still starting', () => { + const summary = describeBlockingBackgroundWork( + createEnumeratingMockConfig({ startingWorkflows: ['wf_starting'] }), + ); + + expect(summary?.lines).toEqual([' [wf_starting] (starting)']); + expect(summary?.hasTaskEntries).toBe(false); + expect(summary?.hasStartingWorkflowRuns).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('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('Use '); + 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.'); + 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', () => { @@ -398,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', () => { diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.ts index 56892a8b4fe..d1376a98cb3 100644 --- a/packages/cli/src/ui/utils/backgroundWorkUtils.ts +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.ts @@ -65,19 +65,22 @@ 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; } /** * 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: `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. */ export function describeBlockingBackgroundWork( config: Config, @@ -87,8 +90,9 @@ export function describeBlockingBackgroundWork( startTime: number; id: string; label: string; - status: TaskStatus | WorkflowStatus; + status: TaskStatus | WorkflowStatus | 'starting'; isWorkflowRun: boolean; + isStarting?: boolean; }> = []; for (const entry of config.getBackgroundTaskRegistry().getAll()) { @@ -130,6 +134,23 @@ 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: 'starting', + isWorkflowRun: true, + isStarting: true, + }); + } if (entries.length === 0) return undefined; @@ -140,6 +161,9 @@ export function describeBlockingBackgroundWork( stripUnsafeCharacters(entry.label).replace(/[\r\n]+/g, ' '), MAX_BLOCKING_LABEL_WIDTH, ); + // 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 }, @@ -152,7 +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), }; } @@ -160,8 +187,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, @@ -171,11 +199,14 @@ 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.`, + ...(surfaces ? [`Use ${surfaces} to inspect them, then retry.`] : []), + ...(blocking.hasStartingWorkflowRuns + ? ['Retry once the run has finished starting.'] + : []), ].join('\n'); } diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index 243477cde42..c24fa7a9d82 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -8,14 +8,18 @@ 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, + WorkflowStartCancelledError, } from './workflow-runner.js'; import { compileWorkflowScript } from './workflow-sandbox.js'; @@ -23,12 +27,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 +62,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 +109,7 @@ describe('WorkflowRunner', () => { createProductionDispatchMock.mockReset(); journalWrites.length = 0; logWorkflowRunMock.mockClear(); + resolveSavedWorkflowScriptMock.mockReset(); writeLineMock.mockReset(); writeLineMock.mockResolvedValue(undefined); writeWorkflowSnapshotMock.mockClear(); @@ -217,7 +232,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 +285,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('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('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 { + 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 () => { @@ -278,6 +439,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 acd21beb4e1..190ac35ba94 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'; @@ -101,103 +103,155 @@ export class WorkflowScriptNotLaunchedError extends 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('Workflow start was cancelled.'); + this.name = 'WorkflowStartCancelledError'; + } +} + 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; + // 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; + 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; } @@ -363,7 +417,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.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index d2bad316663..de4a9a812dc 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(); @@ -1030,6 +1073,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'; @@ -1049,6 +1118,59 @@ 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('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( @@ -1677,6 +1799,74 @@ 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); + + 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('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 45ba5fc0c46..3ac33a34ece 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 @@ -363,6 +428,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; @@ -372,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; @@ -379,6 +453,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 +525,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; @@ -498,20 +591,90 @@ export class WorkflowRunRegistry { } } + /** + * 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, + 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); + } + + /** + * 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 + * 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): WorkflowTask { + 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'; @@ -596,7 +759,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( @@ -1171,6 +1336,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, @@ -1208,6 +1388,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; @@ -1259,6 +1440,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); @@ -1344,8 +1528,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)); @@ -1356,9 +1542,21 @@ export class WorkflowRunRegistry { for (const e of toEvict) { this.entries.delete(e.runId); } + // 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 { + private emitStatusChange(entry?: WorkflowTask): void { if (!this.statusChangeCallback) return; try { this.statusChangeCallback(entry); 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; } } diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index 1a5386f8a55..5e237698fba 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -663,6 +663,11 @@ await agent('scan package.json') 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 = { @@ -678,6 +683,32 @@ await agent('scan package.json') ).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()); @@ -701,6 +732,92 @@ 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('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 18d3970240d..d48a4a61841 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'; @@ -361,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 { @@ -381,8 +382,18 @@ class WorkflowToolInvocation extends BaseToolInvocation< : undefined, }); } catch (error) { - if (runInBackground && signal.aborted) { - return backgroundStartCancelledResult(); + // 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 ( + error instanceof WorkflowStartCancelledError || + (runInBackground && signal.aborted) + ) { + 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 @@ -522,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.', @@ -972,6 +983,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 { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 7776114dc38..aa60ba90c8e 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -117,6 +117,8 @@ const rootDir = join(__dirname, '..'); // avoid complete Web Shell projection on every streamed text update. // Bumped from 208KB to 215KB for the complete standalone-session lifecycle, // response validation, and outcome-unknown recovery surface. +// Workflow task status and control APIs merge within this budget; keep the +// measured combined bundle bounded here. const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 215 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 1dc769a1d0c..af4a57ae877 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -73,8 +73,10 @@ import type { DaemonUsageRange, DaemonStatusReport, DaemonStatusReportDetail, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, DaemonUpdateAgentRequest, DaemonWorkspaceFile, DaemonWorkspaceFileBytes, @@ -3288,6 +3290,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, @@ -3307,24 +3325,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 146db29ae7a..3f5f10d3a1a 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, GoalControlRequest, GoalStateResponse, @@ -984,13 +986,17 @@ export class DaemonSessionClient { return this.client.sessionTasks(this.sessionId, this.clientId); } + workflowTasks(): Promise { + return this.client.sessionWorkflowTasks(this.sessionId, this.clientId); + } + lspStatus(): Promise { return this.client.sessionLspStatus(this.sessionId, this.clientId); } cancelTask( taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], ): Promise<{ cancelled: boolean }> { return this.client.sessionTaskCancel( this.sessionId, @@ -1000,6 +1006,22 @@ export class DaemonSessionClient { ); } + controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }> { + return this.client.sessionWorkflowTaskAction( + this.sessionId, + taskId, + action, + this.clientId, + ); + } + clearGoal(): Promise<{ cleared: boolean; condition?: string }> { return 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 032dbeefb7d..979676c783f 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -440,7 +440,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 c844f70547d..0462afae63e 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -592,6 +592,12 @@ export type { DaemonSessionLspStatus, DaemonSessionAgentTaskStatus, DaemonSessionMonitorTaskStatus, + DaemonSessionWorkflowTaskStatus, + DaemonWorkflowApprovalStatusEntry, + DaemonWorkflowDispatchStatus, + DaemonWorkflowDispatchStatusEntry, + DaemonWorkflowEvent, + DaemonWorkflowPhaseVisit, DaemonSessionProcessTaskLifecycleStatus, DaemonSessionContextUsage, DaemonSessionContextUsageStatus, @@ -623,7 +629,9 @@ export type { DaemonSessionSupportedCommandsStatus, DaemonSessionTaskLifecycleStatus, DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTasksStatus, DaemonSessionStatsStatus, DaemonSessionStatsModelMetrics, DaemonSessionStatsSource, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 5c4a1814ade..ea7200a4435 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -111,6 +111,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. */ @@ -2517,6 +2519,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 = @@ -2605,11 +2614,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; @@ -2617,6 +2743,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 83f30a10917..f8d9ea1ed1b 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -214,6 +214,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, @@ -243,7 +249,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 81df66e260b..469e55a44f4 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1668,6 +1668,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); } @@ -1684,6 +1689,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); @@ -1691,6 +1699,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([ @@ -1698,6 +1710,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 6029747c96f..dafa81be17b 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -1843,7 +1843,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', @@ -1930,6 +1933,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', @@ -1964,6 +1973,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', @@ -1984,6 +1994,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/vscode-ide-companion/src/webview/EmbeddedApp.tsx b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx index 6deddaa4cfe..115637842fb 100644 --- a/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx +++ b/packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx @@ -323,10 +323,13 @@ export function EmbeddedApp() { const [switchingSessionId, setSwitchingSessionId] = useState(); const [creatingSession, setCreatingSession] = useState(false); const [editingMessage, setEditingMessage] = useState(); - const latestSubmittedPromptRef = useRef<{ - sessionId: string; - prompt: string; - } | undefined>(undefined); + const latestSubmittedPromptRef = useRef< + | { + sessionId: string; + prompt: string; + } + | undefined + >(undefined); const sessionSwitchStartedAtRef = useRef(0); const sessionSwitchTimerRef = useRef< ReturnType | undefined diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index 701d36aaffb..0735e76e4e0 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -242,9 +242,7 @@ function getSubcommandArgumentHint( ): string | undefined { if (!argumentHint) return undefined; const match = argumentHint.match( - new RegExp( - `(?:^|\\||\\[)\\s*${escapeRegExp(name)}(?:\\s+([^|\\]]+))?`, - ), + new RegExp(`(?:^|\\||\\[)\\s*${escapeRegExp(name)}(?:\\s+([^|\\]]+))?`), ); const suffix = match?.[1]?.trim(); return suffix || undefined; @@ -553,9 +551,7 @@ export function getSlashCommandCompletionResult( id: command, label: node.name, detail: node.description || undefined, - ...(node.argumentHint - ? { argumentHint: node.argumentHint } - : {}), + ...(node.argumentHint ? { argumentHint: node.argumentHint } : {}), apply: `${command} `, ...(isSkillList ? { type: 'skill' as const } : {}), ...(!node.children?.length && !node.argumentHint @@ -616,8 +612,7 @@ export function getSlashCommandCompletionResult( ...(showCommandInfo && command.description ? { type: 'command-info' as const } : {}), - ...(command.autoSubmit && - !hasSubcommandPicker(command, language) + ...(command.autoSubmit && !hasSubcommandPicker(command, language) ? { autoSubmit: true } : {}), }; @@ -715,9 +710,7 @@ export function slashCompletionSource( detail: n.description || undefined, ...(n.argumentHint ? { argumentHint: n.argumentHint } : {}), apply: `${command} `, - ...(n.children?.length || n.argumentHint - ? {} - : { autoSubmit: true }), + ...(n.children?.length || n.argumentHint ? {} : { autoSubmit: true }), }; }); diff --git a/packages/web-shell/client/components/InsightReady.tsx b/packages/web-shell/client/components/InsightReady.tsx index 6f46197859a..378654cd865 100644 --- a/packages/web-shell/client/components/InsightReady.tsx +++ b/packages/web-shell/client/components/InsightReady.tsx @@ -6,10 +6,7 @@ interface InsightReadyProps { onInsightReportOpen?: (path: string) => void; } -export function InsightReady({ - path, - onInsightReportOpen, -}: InsightReadyProps) { +export function InsightReady({ path, onInsightReportOpen }: InsightReadyProps) { const { t } = useI18n(); return (
diff --git a/packages/web-shell/client/components/dialogs/ModelDialog.tsx b/packages/web-shell/client/components/dialogs/ModelDialog.tsx index 7f75360811d..49aa9a03baf 100644 --- a/packages/web-shell/client/components/dialogs/ModelDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ModelDialog.tsx @@ -101,14 +101,11 @@ export function ModelDialog({ }: ModelDialogProps) { const connection = useConnection(); const currentModel = currentModelId ?? connection.currentModel ?? ''; - const availableModels = useMemo( - () => { - const candidates = - models ?? ((connection.models ?? []) as ModelDialogModel[]); - return filterModel ? candidates.filter(filterModel) : candidates; - }, - [models, connection.models, filterModel], - ); + const availableModels = useMemo(() => { + const candidates = + models ?? ((connection.models ?? []) as ModelDialogModel[]); + return filterModel ? candidates.filter(filterModel) : candidates; + }, [models, connection.models, filterModel]); const { t } = useI18n(); const listRef = useRef(null); const isFastMode = mode === 'fast'; diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index 477f2f19346..d0bee9dda37 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -1722,6 +1722,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); @@ -1880,6 +1897,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'); @@ -3475,6 +3554,7 @@ function createMockSession( listWorkspaceSessions: vi.fn(), listStandaloneSessions: vi.fn(), closeSession: vi.fn(), + sessionWorkflowTaskAction: vi.fn(), removeSessionAttachment: vi.fn(async () => true), }, cancel: vi.fn(async () => undefined), @@ -3504,6 +3584,12 @@ function createMockSession( supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)), stats: vi.fn(), tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })), + workflowTasks: vi.fn(async () => ({ + v: 1 as const, + sessionId, + tasks: [], + })), + controlWorkflowTask: vi.fn(), goal: vi.fn(), controlGoal: vi.fn(), }; diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index d8539191ec9..551fd781528 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -20,7 +20,7 @@ import type { DaemonRewindResult, DaemonSessionRecapResult, DaemonRewindSnapshotInfo, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionArtifactsEnvelope, DaemonTranscriptStore, DaemonCapabilities, @@ -2193,7 +2193,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, @@ -2215,6 +2251,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/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 9d26beee42f..4326e1b2943 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -30,8 +30,10 @@ import type { DaemonSession, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, DaemonSessionStatsStatus, DaemonSessionArtifactsEnvelope, DaemonSkillToggleMutation, @@ -250,6 +252,8 @@ export type DaemonNoticeOperation = | 'read_attachment' | 'remove_attachment' | 'cancel_task' + | 'control_workflow' + | 'run_saved_workflow' | 'load_goal' | 'control_goal' | 'clear_goal' @@ -549,10 +553,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; + }>; getGoal(): Promise; controlGoal(request: GoalControlRequest): Promise; /**