fix: keep syncing partial OpenClaw artifacts

This commit is contained in:
Haitao Pan 2026-06-07 07:38:04 +08:00
parent 7b0502323b
commit f81c4e8c76
5 changed files with 203 additions and 1 deletions

View File

@ -799,7 +799,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
var wroteArtifact = false;
var failedArtifact = false;
var skippedArtifact = false;
final previousSyncStatus =
existingThread.lastArtifactSyncStatus?.trim().toLowerCase() ?? '';
final preserveExistingArtifactPaths =
previousSyncStatus == 'partial' ||
previousSyncStatus == 'syncing' ||
previousSyncStatus == 'running' ||
previousSyncStatus == 'queued';
final currentTaskArtifactPaths = <String>{};
if (preserveExistingArtifactPaths) {
for (final relativePath in existingThread.lastTaskArtifactRelativePaths) {
final sanitized = _sanitizeArtifactRelativePathInternal(relativePath);
if (sanitized.isNotEmpty && !artifactSyncPolicy.ignores(sanitized)) {
currentTaskArtifactPaths.add(sanitized);
}
}
}
for (final artifact in artifacts) {
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,

View File

@ -388,7 +388,13 @@ extension AppControllerDesktopThreadSessions on AppController {
artifactRelativePaths:
thread?.lastTaskArtifactRelativePaths ?? const <String>[],
);
if (snapshot.fileEntries.isNotEmpty || thread == null) {
if (thread == null) {
return snapshot;
}
final shouldRefreshRemote = _shouldRefreshRemoteArtifactSnapshotInternal(
thread,
);
if (snapshot.fileEntries.isNotEmpty && !shouldRefreshRemote) {
return snapshot;
}
final synced = await syncRemoteTaskArtifactsForSessionInternal(
@ -406,6 +412,18 @@ extension AppControllerDesktopThreadSessions on AppController {
);
}
bool _shouldRefreshRemoteArtifactSnapshotInternal(TaskThread thread) {
final syncStatus = thread.lastArtifactSyncStatus?.trim().toLowerCase();
if (syncStatus == 'partial' ||
syncStatus == 'syncing' ||
syncStatus == 'running' ||
syncStatus == 'queued') {
return true;
}
final association = thread.openClawTaskAssociation;
return association != null && !association.isTerminal;
}
Future<bool> syncRemoteTaskArtifactsForSessionInternal(
String sessionKey,
) async {

View File

@ -76,11 +76,13 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
bool _loadingSnapshot = false;
bool _loadingPreview = false;
bool _taskContextExpanded = false;
Timer? _refreshTimer;
@override
void initState() {
super.initState();
unawaited(_refreshSnapshot());
_syncRefreshTimer();
}
@override
@ -96,6 +98,13 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
_preview = const AssistantArtifactPreview.empty();
unawaited(_refreshSnapshot());
}
_syncRefreshTimer();
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
@override
@ -449,6 +458,9 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
}
Future<void> _refreshSnapshot() async {
if (_loadingSnapshot) {
return;
}
setState(() {
_loadingSnapshot = true;
_loadError = null;
@ -482,6 +494,36 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
}
}
void _syncRefreshTimer() {
final shouldPoll = _shouldPollArtifactSnapshot(widget.artifactSyncStatus);
if (!shouldPoll) {
_refreshTimer?.cancel();
_refreshTimer = null;
return;
}
if (_refreshTimer?.isActive == true) {
return;
}
_refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (!mounted || _loadingSnapshot) {
return;
}
unawaited(_refreshSnapshot());
});
}
bool _shouldPollArtifactSnapshot(String status) {
switch (status.trim().toLowerCase()) {
case 'partial':
case 'syncing':
case 'running':
case 'queued':
return true;
default:
return false;
}
}
AssistantArtifactEntry? _reconcileSelection(
AssistantArtifactSnapshot snapshot, {
AssistantArtifactEntry? previous,

View File

@ -46,6 +46,46 @@ void main() {
expect(find.text('artifact-2.txt'), findsAtLeastNWidgets(1));
});
testWidgets('keeps polling partial artifact snapshots', (tester) async {
var loadCount = 0;
await tester.pumpWidget(
_buildTestApp(
artifactSyncAtMs: 1,
artifactSyncStatus: 'partial',
loadSnapshot: () async {
loadCount += 1;
return AssistantArtifactSnapshot(
workspacePath: '/tmp/thread',
workspaceKind: WorkspaceRefKind.localPath,
fileEntries: <AssistantArtifactEntry>[
AssistantArtifactEntry(
id: 'entry-$loadCount',
label: 'artifact-$loadCount.txt',
relativePath: 'artifact-$loadCount.txt',
kind: AssistantArtifactEntryKind.file,
mimeType: 'text/plain',
previewable: true,
workspacePath: '/tmp/thread',
),
],
);
},
),
);
await tester.pump();
expect(loadCount, 1);
await tester.pump(const Duration(milliseconds: 3100));
await tester.pump();
expect(loadCount, greaterThanOrEqualTo(2));
expect(find.text('artifact-2.txt'), findsAtLeastNWidgets(1));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('explains OpenClaw runs with no exported artifacts', (
tester,
) async {

View File

@ -1021,6 +1021,93 @@ void main() {
},
);
test(
'refreshing a partial artifact snapshot keeps backfilling OpenClaw task artifacts',
() async {
var getTaskCount = 0;
late OpenClawTaskAssociation observedAssociation;
final goTaskClient = _ArtifactBackfillGoTaskServiceClient(
onGetTask: (association) {
getTaskCount += 1;
observedAssociation = association;
},
);
final controller = AppController(
environmentOverride: const <String, String>{},
goTaskServiceClient: goTaskClient,
);
addTearDown(controller.dispose);
final taskWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-partial-association-backfill-',
);
addTearDown(() async {
if (await taskWorkspace.exists()) {
await taskWorkspace.delete(recursive: true);
}
});
await Directory(
'${taskWorkspace.path}/assets/images',
).create(recursive: true);
await File(
'${taskWorkspace.path}/assets/images/09-AI-Agent.v32.png',
).writeAsBytes(<int>[1, 2, 3]);
const sessionKey = 'draft-partial-sync';
const runId = 'turn-partial';
const openClawSessionKey = 'agent:main:draft:partial-sync';
controller.upsertTaskThreadInternal(
sessionKey,
workspaceBinding: WorkspaceBinding(
workspaceId: sessionKey,
workspaceKind: WorkspaceKind.localFs,
workspacePath: taskWorkspace.path,
displayPath: taskWorkspace.path,
writable: true,
),
openClawTaskAssociation: const OpenClawTaskAssociation(
sessionId: sessionKey,
threadId: sessionKey,
turnId: runId,
runId: runId,
artifactScope: 'tasks/$openClawSessionKey/$runId',
artifactDirectory:
'/home/ubuntu/.openclaw/workspace/tasks/$openClawSessionKey/$runId',
gatewayProviderId: 'openclaw',
startedAtMs: 1,
status: 'completed',
appThreadKey: 'draft:partial-sync',
openclawSessionKey: openClawSessionKey,
),
lastArtifactSyncStatus: 'partial',
lastTaskArtifactRelativePaths: const <String>[
'assets/images/09-AI-Agent.v32.png',
],
);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: sessionKey,
);
expect(getTaskCount, 1);
expect(observedAssociation.runId, runId);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
containsAll(<String>[
'assets/images/09-AI-Agent.v32.png',
'ai-news-report.md',
]),
);
expect(
await File('${taskWorkspace.path}/ai-news-report.md').readAsString(),
'# AI news\n',
);
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 {