fix: keep OpenClaw artifact sync polling

This commit is contained in:
Haitao Pan 2026-06-06 18:02:13 +08:00
parent ebe505b59c
commit 38254caa4d
7 changed files with 350 additions and 13 deletions

View File

@ -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,

View File

@ -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 <String>[],
taskContextRemoteWorkingDirectory:
activeThread?.lastRemoteWorkingDirectory ?? '',
taskContextOpenClawRunId:
activeThread?.openClawTaskAssociation?.runId ?? '',
taskContextOpenClawStatus:
activeThread?.openClawTaskAssociation?.status ?? '',
onCollapse: () {
setState(() {
artifactPaneCollapsedInternal = true;

View File

@ -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 =>

View File

@ -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<String> 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<AssistantArtifactSidebar> {
Object? _loadError;
bool _loadingSnapshot = false;
bool _loadingPreview = false;
bool _taskContextExpanded = false;
@override
void initState() {
@ -259,6 +270,24 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
],
),
),
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<String> 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,

View File

@ -222,6 +222,12 @@ Widget _buildTestApp({
workspaceKind: WorkspaceRefKind.localPath,
artifactSyncAtMs: artifactSyncAtMs,
artifactSyncStatus: artifactSyncStatus,
taskContextMessageCount: 2,
taskContextSelectedSkillKeys: const <String>['openclaw'],
taskContextRemoteWorkingDirectory:
'/home/ubuntu/.openclaw/workspace/tasks/unit/run',
taskContextOpenClawRunId: 'run',
taskContextOpenClawStatus: 'syncing-artifacts',
onCollapse: () {},
loadSnapshot: loadSnapshot,
loadPreview:

View File

@ -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 <String, String>{},
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: <String, dynamic>{
'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 <String>[],
);
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 {

View File

@ -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,
);
},
);