fix: keep OpenClaw artifact sync polling
This commit is contained in:
parent
ebe505b59c
commit
38254caa4d
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user