From 38254caa4d94aa9897bbb9a5bb46a11b4968f435 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 6 Jun 2026 18:02:13 +0800 Subject: [PATCH] fix: keep OpenClaw artifact sync polling --- ...app_controller_desktop_thread_actions.dart | 35 ++- .../assistant_page_state_closure.dart | 12 + lib/runtime/go_task_service_client.dart | 6 +- lib/widgets/assistant_artifact_sidebar.dart | 207 ++++++++++++++++++ .../assistant_artifact_sidebar_test.dart | 6 + ...troller_thread_workspace_binding_test.dart | 81 +++++++ .../assistant_execution_target_test.dart | 16 +- 7 files changed, 350 insertions(+), 13 deletions(-) diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 578546e3..fc03373d 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -735,7 +735,6 @@ extension AppControllerDesktopThreadActions on AppController { required OpenClawTaskAssociation association, }) async { var current = association; - var artifactRetries = 0; var firstAttempt = true; while (true) { if (disposedInternal) { @@ -754,6 +753,9 @@ extension AppControllerDesktopThreadActions on AppController { target: target, association: current, ); + if (disposedInternal) { + return; + } final nextAssociation = result.openClawTaskAssociation ?? current.copyWith( @@ -779,8 +781,32 @@ extension AppControllerDesktopThreadActions on AppController { a.relativePath.toLowerCase().endsWith(ext.toLowerCase()), ); }); - if (!hasEnoughArtifacts && artifactRetries < 3) { - artifactRetries += 1; + if (!hasEnoughArtifacts) { + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + current = current.copyWith(status: 'syncing-artifacts'); + upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: 'running', + lastResultCode: 'running', + lastRemoteWorkingDirectory: + result.remoteWorkingDirectory.trim().isEmpty + ? taskThreadForSessionInternal( + sessionKey, + )?.lastRemoteWorkingDirectory + : result.remoteWorkingDirectory.trim(), + lastRemoteWorkspaceRefKind: + result.remoteWorkspaceRefKind ?? + taskThreadForSessionInternal( + sessionKey, + )?.lastRemoteWorkspaceRefKind, + lastArtifactSyncAtMs: nowMs, + lastArtifactSyncStatus: 'syncing', + openClawTaskAssociation: current, + updatedAtMs: nowMs, + ); + recomputeTasksInternal(); + notifyIfActiveInternal(); + unawaited(flushAssistantThreadPersistenceInternal()); continue; } await applyGatewayChatResultInternal( @@ -795,6 +821,9 @@ extension AppControllerDesktopThreadActions on AppController { notifyIfActiveInternal(); return; } catch (error) { + if (disposedInternal) { + return; + } if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { await applyGatewayChatFailureInternal( sessionKey: sessionKey, diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 00e088ed..d1340e13 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -305,6 +305,9 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { final activeSessionKey = currentTask.sessionKey.trim().isEmpty ? controller.currentSessionKey : currentTask.sessionKey.trim(); + final activeThread = controller.taskThreadForSessionInternal( + activeSessionKey, + ); final panel = Row( children: [ Expanded(child: child), @@ -347,6 +350,15 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { .assistantArtifactSyncAtMsForSession(activeSessionKey), artifactSyncStatus: controller .assistantArtifactSyncStatusForSession(activeSessionKey), + taskContextMessageCount: activeThread?.messages.length ?? 0, + taskContextSelectedSkillKeys: + activeThread?.selectedSkillKeys ?? const [], + taskContextRemoteWorkingDirectory: + activeThread?.lastRemoteWorkingDirectory ?? '', + taskContextOpenClawRunId: + activeThread?.openClawTaskAssociation?.runId ?? '', + taskContextOpenClawStatus: + activeThread?.openClawTaskAssociation?.status ?? '', onCollapse: () { setState(() { artifactPaneCollapsedInternal = true; diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index 1ba27242..6a07fa3a 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -529,10 +529,12 @@ class GoTaskServiceResult { } OpenClawTaskAssociation? get openClawTaskAssociation { - if (!isOpenClawRunningTaskHandle) { + final association = OpenClawTaskAssociation.fromJsonOrNull(raw); + if (association == null) { return null; } - return OpenClawTaskAssociation.fromJsonOrNull(raw); + final provider = association.gatewayProviderId.trim().toLowerCase(); + return provider.contains('openclaw') ? association : null; } String get resolvedExecutionTarget => diff --git a/lib/widgets/assistant_artifact_sidebar.dart b/lib/widgets/assistant_artifact_sidebar.dart index bd853975..cfc3b0f8 100644 --- a/lib/widgets/assistant_artifact_sidebar.dart +++ b/lib/widgets/assistant_artifact_sidebar.dart @@ -33,6 +33,11 @@ class AssistantArtifactSidebar extends StatefulWidget { required this.workspaceKind, required this.artifactSyncAtMs, required this.artifactSyncStatus, + required this.taskContextMessageCount, + required this.taskContextSelectedSkillKeys, + required this.taskContextRemoteWorkingDirectory, + required this.taskContextOpenClawRunId, + required this.taskContextOpenClawStatus, required this.onCollapse, required this.loadSnapshot, required this.loadPreview, @@ -46,6 +51,11 @@ class AssistantArtifactSidebar extends StatefulWidget { final WorkspaceRefKind workspaceKind; final double? artifactSyncAtMs; final String artifactSyncStatus; + final int taskContextMessageCount; + final List taskContextSelectedSkillKeys; + final String taskContextRemoteWorkingDirectory; + final String taskContextOpenClawRunId; + final String taskContextOpenClawStatus; final VoidCallback onCollapse; final AssistantArtifactSnapshotLoader loadSnapshot; final AssistantArtifactPreviewLoader loadPreview; @@ -65,6 +75,7 @@ class _AssistantArtifactSidebarState extends State { Object? _loadError; bool _loadingSnapshot = false; bool _loadingPreview = false; + bool _taskContextExpanded = false; @override void initState() { @@ -259,6 +270,24 @@ class _AssistantArtifactSidebarState extends State { ], ), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: _TaskContextSummaryCard( + messageCount: widget.taskContextMessageCount, + selectedSkillKeys: widget.taskContextSelectedSkillKeys, + remoteWorkingDirectory: widget.taskContextRemoteWorkingDirectory, + openClawRunId: widget.taskContextOpenClawRunId, + openClawStatus: widget.taskContextOpenClawStatus, + artifactSyncStatus: widget.artifactSyncStatus, + expanded: _taskContextExpanded, + onToggle: () { + setState(() { + _taskContextExpanded = !_taskContextExpanded; + }); + }, + ), + ), + const SizedBox(height: AppSpacing.sm), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: SectionTabs( @@ -577,6 +606,184 @@ class AssistantArtifactSidebarRevealButton extends StatelessWidget { } } +class _TaskContextSummaryCard extends StatelessWidget { + const _TaskContextSummaryCard({ + required this.messageCount, + required this.selectedSkillKeys, + required this.remoteWorkingDirectory, + required this.openClawRunId, + required this.openClawStatus, + required this.artifactSyncStatus, + required this.expanded, + required this.onToggle, + }); + + final int messageCount; + final List selectedSkillKeys; + final String remoteWorkingDirectory; + final String openClawRunId; + final String openClawStatus; + final String artifactSyncStatus; + final bool expanded; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + final skills = selectedSkillKeys + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + final status = artifactSyncStatus.trim().isEmpty + ? appText('未同步', 'Not synced') + : artifactSyncStatus.trim(); + return Container( + decoration: BoxDecoration( + color: palette.chromeSurface.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(AppRadius.button), + border: Border.all(color: palette.chromeStroke), + ), + child: Column( + children: [ + InkWell( + key: const Key('assistant-artifact-task-context-toggle'), + onTap: onToggle, + borderRadius: BorderRadius.circular(AppRadius.button), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + child: Row( + children: [ + Icon( + Icons.forum_outlined, + size: 16, + color: palette.textSecondary, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + appText('任务上下文会话', 'Task context session'), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '$messageCount msg · $status', + style: theme.textTheme.labelSmall?.copyWith( + color: palette.textMuted, + ), + ), + const SizedBox(width: AppSpacing.xxs), + Icon( + expanded + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 18, + color: palette.textSecondary, + ), + ], + ), + ), + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 140), + crossFadeState: expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox(width: double.infinity), + secondChild: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + 0, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TaskContextLine( + label: appText('消息', 'Messages'), + value: '$messageCount', + ), + _TaskContextLine( + label: appText('技能', 'Skills'), + value: skills.isEmpty + ? appText('未选择', 'None') + : skills.join(', '), + ), + _TaskContextLine( + label: appText('远端路径', 'Remote path'), + value: remoteWorkingDirectory.trim().isEmpty + ? appText('未记录', 'Not recorded') + : remoteWorkingDirectory.trim(), + ), + _TaskContextLine( + label: appText('OpenClaw run', 'OpenClaw run'), + value: openClawRunId.trim().isEmpty + ? appText('未绑定', 'Not bound') + : openClawRunId.trim(), + ), + _TaskContextLine( + label: appText('OpenClaw 状态', 'OpenClaw status'), + value: openClawStatus.trim().isEmpty + ? appText('未记录', 'Not recorded') + : openClawStatus.trim(), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _TaskContextLine extends StatelessWidget { + const _TaskContextLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.xxs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 86, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: palette.textMuted, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: palette.textSecondary, + ), + ), + ), + ], + ), + ); + } +} + class _ArtifactEntryList extends StatelessWidget { const _ArtifactEntryList({ super.key, diff --git a/test/features/assistant/assistant_artifact_sidebar_test.dart b/test/features/assistant/assistant_artifact_sidebar_test.dart index b9bf4a83..61cc1a68 100644 --- a/test/features/assistant/assistant_artifact_sidebar_test.dart +++ b/test/features/assistant/assistant_artifact_sidebar_test.dart @@ -222,6 +222,12 @@ Widget _buildTestApp({ workspaceKind: WorkspaceRefKind.localPath, artifactSyncAtMs: artifactSyncAtMs, artifactSyncStatus: artifactSyncStatus, + taskContextMessageCount: 2, + taskContextSelectedSkillKeys: const ['openclaw'], + taskContextRemoteWorkingDirectory: + '/home/ubuntu/.openclaw/workspace/tasks/unit/run', + taskContextOpenClawRunId: 'run', + taskContextOpenClawStatus: 'syncing-artifacts', onCollapse: () {}, loadSnapshot: loadSnapshot, loadPreview: diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index e180009b..9ea84bde 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -940,6 +940,87 @@ void main() { }, ); + test( + 'refreshing an empty artifact snapshot backfills completed OpenClaw task artifacts from recorded association', + () async { + late OpenClawTaskAssociation observedAssociation; + final goTaskClient = _ArtifactBackfillGoTaskServiceClient( + onGetTask: (association) { + observedAssociation = association; + }, + ); + final controller = AppController( + environmentOverride: const {}, + goTaskServiceClient: goTaskClient, + ); + addTearDown(controller.dispose); + + final taskWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-completed-association-backfill-', + ); + addTearDown(() async { + if (await taskWorkspace.exists()) { + await taskWorkspace.delete(recursive: true); + } + }); + + const sessionKey = 'draft-completed-sync'; + const runId = 'turn-completed'; + const openClawSessionKey = 'agent:main:draft:completed-sync'; + final completedResult = GoTaskServiceResult( + success: true, + message: 'completed without inline artifacts', + turnId: runId, + raw: { + 'success': true, + 'status': 'completed', + 'sessionId': sessionKey, + 'threadId': sessionKey, + 'turnId': runId, + 'runId': runId, + 'artifactScope': 'tasks/$openClawSessionKey/$runId', + 'artifactDirectory': + '/home/ubuntu/.openclaw/workspace/tasks/$openClawSessionKey/$runId', + 'gatewayProviderId': 'openclaw', + 'appThreadKey': 'draft:completed-sync', + 'openclawSessionKey': openClawSessionKey, + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + controller.upsertTaskThreadInternal( + sessionKey, + workspaceBinding: WorkspaceBinding( + workspaceId: sessionKey, + workspaceKind: WorkspaceKind.localFs, + workspacePath: taskWorkspace.path, + displayPath: taskWorkspace.path, + writable: true, + ), + openClawTaskAssociation: completedResult.openClawTaskAssociation, + lastRemoteWorkingDirectory: taskWorkspace.path, + lastArtifactSyncStatus: 'no-artifacts', + lastTaskArtifactRelativePaths: const [], + ); + + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: sessionKey, + ); + + expect(observedAssociation.runId, runId); + expect(observedAssociation.openclawSessionKey, openClawSessionKey); + expect( + snapshot.fileEntries.map((entry) => entry.relativePath), + contains('ai-news-report.md'), + ); + final thread = controller.requireTaskThreadForSessionInternal(sessionKey); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.openClawTaskAssociation?.status, 'completed'); + }, + ); + test( 'resumes bridge artifact downloads after a weak network disconnect', () async { diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 33dd3cb6..b52abe40 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -4055,7 +4055,7 @@ void main() { }); test( - 'OpenClaw terminal snapshot without required artifacts does not stay running', + 'OpenClaw terminal snapshot without required artifacts keeps polling', () async { final fakeGoTaskService = _RecordingGoTaskServiceClient() ..outcomes.add( @@ -4116,28 +4116,28 @@ void main() { await _waitForThreadLifecycleStatusWithin( controller, 'openclaw-missing-screenshot', - 'ready', + 'running', const Duration(seconds: 10), ); await _waitForThreadArtifactSyncStatusWithin( controller, 'openclaw-missing-screenshot', - 'no-artifacts', + 'syncing', const Duration(seconds: 10), ); final thread = controller.requireTaskThreadForSessionInternal( 'openclaw-missing-screenshot', ); - expect(thread.lifecycleState.status, 'ready'); - expect(thread.lifecycleState.lastResultCode, 'success'); - expect(thread.lastArtifactSyncStatus, 'no-artifacts'); - expect(thread.openClawTaskAssociation, isNull); + expect(thread.lifecycleState.status, 'running'); + expect(thread.lifecycleState.lastResultCode, 'running'); + expect(thread.lastArtifactSyncStatus, 'syncing'); + expect(thread.openClawTaskAssociation?.status, 'syncing-artifacts'); expect( controller.assistantSessionHasPendingRun( 'openclaw-missing-screenshot', ), - isFalse, + isTrue, ); }, );