Reassociate OpenClaw tasks through Bridge control plane

This commit is contained in:
Haitao Pan 2026-06-02 11:58:40 +08:00
parent 177fdcb46a
commit 55a1ce2af4
11 changed files with 567 additions and 331 deletions

View File

@ -76,6 +76,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
bool clearOpenClawTaskAssociation = false,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@ -225,6 +227,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
lastArtifactSyncStatus: lastArtifactSyncStatus,
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths,
openClawTaskAssociation: openClawTaskAssociation,
clearOpenClawTaskAssociation: clearOpenClawTaskAssociation,
);
final nextStatus =
lifecycleStatus ??
@ -263,6 +267,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
executionBinding: nextExecutionBinding,
contextState: nextContextState,
lifecycleState: nextLifecycleState,
openClawTaskAssociation: nextContextState.openClawTaskAssociation,
updatedAtMs:
updatedAtMs ??
existing?.updatedAtMs ??

View File

@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
@ -64,7 +65,11 @@ extension AppControllerDesktopThreadActions on AppController {
bool assistantSessionHasPendingRun(String sessionKey) {
final normalized = normalizedAssistantSessionKeyInternal(sessionKey);
final association = taskThreadForSessionInternal(
normalized,
)?.openClawTaskAssociation;
return aiGatewayPendingSessionKeysInternal.contains(normalized) ||
(association != null && !association.isTerminal) ||
openClawGatewayQueuedTurnsBySessionInternal[normalized]?.any(
(turn) => !turn.cancelled,
) ==
@ -558,6 +563,7 @@ extension AppControllerDesktopThreadActions on AppController {
appendGatewayUserTurnInternal(sessionKey, message);
}
markGatewayChatRunInternal(sessionKey);
var handedOffToBridgeTask = false;
try {
final result = await goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
@ -590,6 +596,22 @@ extension AppControllerDesktopThreadActions on AppController {
clearAiGatewayStreamingTextInternal(sessionKey);
return;
}
final association = result.openClawTaskAssociation;
if (association != null) {
handedOffToBridgeTask = true;
persistOpenClawTaskAssociationInternal(
sessionKey: sessionKey,
association: association,
);
unawaited(
pollOpenClawTaskAssociationInternal(
sessionKey: sessionKey,
target: target,
association: association,
),
);
return;
}
await applyGatewayChatResultInternal(
sessionKey: sessionKey,
target: target,
@ -610,13 +632,155 @@ extension AppControllerDesktopThreadActions on AppController {
error: error,
);
} finally {
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
clearAiGatewayStreamingTextInternal(sessionKey);
if (!handedOffToBridgeTask) {
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
clearAiGatewayStreamingTextInternal(sessionKey);
}
recomputeTasksInternal();
notifyIfActiveInternal();
}
}
void persistOpenClawTaskAssociationInternal({
required String sessionKey,
required OpenClawTaskAssociation association,
}) {
final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble();
aiGatewayPendingSessionKeysInternal.add(sessionKey);
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'running',
lastResultCode: 'running',
lastRunAtMs: association.startedAtMs > 0
? association.startedAtMs
: nowMs,
lastArtifactSyncAtMs: nowMs,
lastArtifactSyncStatus: 'running',
openClawTaskAssociation: association,
updatedAtMs: nowMs,
);
recomputeTasksInternal();
notifyIfActiveInternal();
unawaited(flushAssistantThreadPersistenceInternal());
}
Future<void> pollOpenClawTaskAssociationInternal({
required String sessionKey,
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
}) async {
var current = association;
final pollDelay = _openClawAssociationPollDelayInternal(current);
final maxAttempts = math.max(
1,
((math.max(1, current.runtimeBudgetMinutes) * 60) /
math.max(1, pollDelay.inSeconds))
.ceil(),
);
for (var attempt = 0; attempt < maxAttempts; attempt += 1) {
if (disposedInternal) {
return;
}
if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
return;
}
if (attempt > 0) {
await Future<void>.delayed(pollDelay);
}
try {
final result = await goTaskServiceClientInternal.getTask(
route: GoTaskServiceRoute.externalAcpSingle,
target: target,
association: current,
);
final nextAssociation =
result.openClawTaskAssociation ??
current.copyWith(
status: result.status.trim().isEmpty
? current.status
: result.status.trim(),
);
current = nextAssociation;
if (result.isOpenClawRunningTaskHandle) {
persistOpenClawTaskAssociationInternal(
sessionKey: sessionKey,
association: nextAssociation,
);
continue;
}
if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
await applyGatewayChatResultInternal(
sessionKey: sessionKey,
target: target,
result: result,
);
}
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
clearAiGatewayStreamingTextInternal(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
return;
} catch (_) {
continue;
}
}
final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble();
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: nowMs,
lastResultCode: 'TASK_SLA_EXPIRED',
lastArtifactSyncAtMs: nowMs,
lastArtifactSyncStatus: 'failed',
openClawTaskAssociation: current.copyWith(status: 'failed'),
updatedAtMs: nowMs,
);
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
clearAiGatewayStreamingTextInternal(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
}
Duration _openClawAssociationPollDelayInternal(
OpenClawTaskAssociation association,
) {
final budget = association.runtimeBudgetMinutes;
if (budget >= 60) {
return const Duration(seconds: 5);
}
if (budget >= 30) {
return const Duration(seconds: 3);
}
return const Duration(seconds: 2);
}
void resumeOpenClawTaskAssociationsInternal({String? onlySessionKey}) {
final normalizedOnly = onlySessionKey == null
? ''
: normalizedAssistantSessionKeyInternal(onlySessionKey);
for (final record in taskThreadRepositoryInternal.snapshot()) {
if (normalizedOnly.isNotEmpty && record.threadId != normalizedOnly) {
continue;
}
final association = record.openClawTaskAssociation;
if (association == null || association.isTerminal) {
continue;
}
aiGatewayPendingSessionKeysInternal.add(record.threadId);
unawaited(
pollOpenClawTaskAssociationInternal(
sessionKey: record.threadId,
target: assistantExecutionTargetFromExecutionMode(
record.executionBinding.executionMode,
),
association: association,
),
);
}
recomputeTasksInternal();
notifyIfActiveInternal();
}
String messageWithSelectedSkillsContextInternal({
required String message,
required List<String> selectedSkillLabels,
@ -844,6 +1008,7 @@ extension AppControllerDesktopThreadActions on AppController {
lastArtifactSyncAtMs: nowMs,
lastArtifactSyncStatus: 'failed',
lastTaskArtifactRelativePaths: const <String>[],
clearOpenClawTaskAssociation: true,
updatedAtMs: nowMs,
);
recomputeTasksInternal();
@ -1017,6 +1182,7 @@ extension AppControllerDesktopThreadActions on AppController {
lifecycleStatus: 'ready',
lastRunAtMs: completedAtMs,
lastResultCode: terminalResultCode,
clearOpenClawTaskAssociation: true,
updatedAtMs: completedAtMs,
);
if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) {
@ -1102,6 +1268,7 @@ extension AppControllerDesktopThreadActions on AppController {
lastArtifactSyncAtMs: completedAtMs,
lastArtifactSyncStatus: 'failed',
lastTaskArtifactRelativePaths: const <String>[],
clearOpenClawTaskAssociation: true,
updatedAtMs: completedAtMs,
);
appendLocalSessionMessageInternal(
@ -1254,6 +1421,9 @@ extension AppControllerDesktopThreadActions on AppController {
target: assistantExecutionTargetForSession(sessionKey),
sessionId: sessionKey,
threadId: sessionKey,
association: taskThreadForSessionInternal(
sessionKey,
)?.openClawTaskAssociation,
);
} catch (_) {
// Best effort cancellation only.
@ -1287,6 +1457,9 @@ extension AppControllerDesktopThreadActions on AppController {
target: assistantExecutionTargetForSession(sessionKey),
sessionId: sessionKey,
threadId: sessionKey,
association: taskThreadForSessionInternal(
sessionKey,
)?.openClawTaskAssociation,
);
} catch (_) {
// Best effort cancellation only. Local state must still leave pending.

View File

@ -145,6 +145,7 @@ extension AppControllerDesktopThreadStorage on AppController {
normalized,
persistSelection: false,
);
resumeOpenClawTaskAssociationsInternal(onlySessionKey: normalized);
}
void handleRuntimeEventInternal(GatewayPushEvent event) {

View File

@ -125,11 +125,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
lastResultCode: thread?.lifecycleState.lastResultCode ?? '',
artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '',
runtimeBudgetMinutes:
gatewayAcpTaskRuntimeBudgetMinutesForParams(<String, dynamic>{
'taskPrompt': currentTask.preview,
'requestedExecutionTarget':
currentTask.executionTarget.promptValue,
}),
thread?.openClawTaskAssociation?.runtimeBudgetMinutes ?? 10,
);
return SurfaceCard(

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
@ -104,6 +103,7 @@ class ExternalCodeAgentAcpDesktopTransport
var streamedText = '';
String? completedMessage;
Map<String, dynamic>? completedResultSnapshot;
Map<String, dynamic>? runningTaskSnapshot;
try {
final endpointOverride = _taskEndpointResolver == null
? _endpointResolver(request.target)
@ -136,6 +136,11 @@ class ExternalCodeAgentAcpDesktopTransport
update,
);
}
if (update.payload['status']?.toString().trim().toLowerCase() ==
'running' &&
(update.payload['runId']?.toString().trim().isNotEmpty == true)) {
runningTaskSnapshot = <String, dynamic>{...update.payload};
}
onUpdate(update);
},
);
@ -155,6 +160,7 @@ class ExternalCodeAgentAcpDesktopTransport
streamedText: streamedText,
completedMessage: completedMessage,
fallbackAvailable: completedResultSnapshot != null,
runningTaskSnapshot: runningTaskSnapshot,
);
if (recovered != null) {
return recovered;
@ -203,11 +209,27 @@ class ExternalCodeAgentAcpDesktopTransport
required String streamedText,
required String? completedMessage,
bool fallbackAvailable = false,
Map<String, dynamic>? runningTaskSnapshot,
}) async {
final endpoint = _sessionSnapshotEndpoint(taskEndpoint);
if (endpoint == null) {
return null;
}
final association = OpenClawTaskAssociation.fromJsonOrNull(
runningTaskSnapshot,
);
if (association != null) {
return goTaskServiceResultFromAcpResponse(
<String, dynamic>{
'jsonrpc': '2.0',
'id': 'recovered-from-running-task-handle',
'result': runningTaskSnapshot,
},
route: request.route,
streamedText: streamedText,
completedMessage: completedMessage,
);
}
final attempts = _recoveryAttemptsForRequest(request);
for (var attempt = 0; attempt < attempts; attempt += 1) {
if (attempt > 0) {
@ -216,7 +238,7 @@ class ExternalCodeAgentAcpDesktopTransport
Map<String, dynamic> response;
try {
response = await _client.request(
method: 'xworkmate.sessions.get',
method: 'xworkmate.tasks.get',
params: <String, dynamic>{
'sessionId': request.sessionId,
'threadId': request.threadId,
@ -232,11 +254,7 @@ class ExternalCodeAgentAcpDesktopTransport
continue;
}
final snapshot = _castMap(response['result']);
final task = _castMap(snapshot['task']);
final status = (task['state'] ?? snapshot['status'] ?? '')
.toString()
.trim()
.toLowerCase();
final status = (snapshot['status'] ?? '').toString().trim().toLowerCase();
final terminal =
status == 'completed' ||
status == 'failed' ||
@ -245,7 +263,19 @@ class ExternalCodeAgentAcpDesktopTransport
if (!terminal) {
continue;
}
final result = _recoveredResultFromSessionSnapshot(snapshot);
if (status == 'failed' || status == 'cancelled' || status == 'canceled') {
return goTaskServiceResultFromAcpResponse(
<String, dynamic>{
'jsonrpc': '2.0',
'id': 'recovered-from-terminal-task-snapshot',
'result': _failureResultFromTaskSnapshot(snapshot, status),
},
route: request.route,
streamedText: streamedText,
completedMessage: completedMessage,
);
}
final result = _recoveredResultFromTaskSnapshot(snapshot);
if (result.isNotEmpty) {
return goTaskServiceResultFromAcpResponse(
<String, dynamic>{
@ -258,34 +288,37 @@ class ExternalCodeAgentAcpDesktopTransport
completedMessage: completedMessage,
);
}
if (status == 'failed' || status == 'cancelled' || status == 'canceled') {
return goTaskServiceResultFromAcpResponse(
<String, dynamic>{
'jsonrpc': '2.0',
'id': 'recovered-from-terminal-session-snapshot',
'result': _failureResultFromSessionSnapshot(snapshot, status),
},
route: request.route,
streamedText: streamedText,
completedMessage: completedMessage,
);
}
}
return null;
}
@override
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
}) async {
final endpoint = _sessionSnapshotEndpoint(_endpointResolver(target));
if (endpoint == null) {
throw const GatewayAcpException(
'xworkmate-bridge is not connected',
code: 'BRIDGE_NOT_CONNECTED',
);
}
final response = await _client.request(
method: 'xworkmate.tasks.get',
params: association.toTaskGetParams(),
endpointOverride: endpoint,
);
return goTaskServiceResultFromAcpResponse(response, route: route);
}
int _recoveryAttemptsForRequest(GoTaskServiceRequest request) {
final configured = _recoveryMaxAttempts;
if (configured != null) {
return configured <= 0 ? 1 : configured;
}
final pollMicros = math.max(1, _recoveryPollDelay.inMicroseconds);
final budgetMicros = Duration(
minutes: gatewayAcpTaskRuntimeBudgetMinutesForParams(
request.toExternalAcpParams(),
),
).inMicroseconds;
return math.max(1, (budgetMicros / pollMicros).ceil());
return 1;
}
Uri? _sessionSnapshotEndpoint(Uri? taskEndpoint) {
@ -303,7 +336,19 @@ class ExternalCodeAgentAcpDesktopTransport
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
}) async {
if (association != null) {
final endpoint = _sessionSnapshotEndpoint(_endpointResolver(target));
if (endpoint != null) {
await _client.request(
method: 'xworkmate.tasks.cancel',
params: association.toTaskGetParams(),
endpointOverride: endpoint,
);
return;
}
}
await _client.cancelSession(
sessionId: sessionId,
threadId: threadId,
@ -359,13 +404,16 @@ class ExternalCodeAgentAcpDesktopTransport
return snapshot;
}
Map<String, dynamic> _recoveredResultFromSessionSnapshot(
Map<String, dynamic> _recoveredResultFromTaskSnapshot(
Map<String, dynamic> snapshot,
) {
final result = <String, dynamic>{..._castMap(snapshot['result'])};
final result = <String, dynamic>{
..._castMap(snapshot['result']),
...snapshot,
};
final artifactRecord = _castMap(snapshot['artifacts']);
final artifactItems = _listValue(artifactRecord['items']);
if (artifactItems.isNotEmpty && !_hasArtifactList(result)) {
final artifactItems = artifactRecord['items'];
if (artifactItems is List && result['artifacts'] == artifactRecord) {
result['artifacts'] = artifactItems;
}
for (final entry in <String, String>{
@ -382,31 +430,23 @@ class ExternalCodeAgentAcpDesktopTransport
return result;
}
Map<String, dynamic> _failureResultFromSessionSnapshot(
Map<String, dynamic> _failureResultFromTaskSnapshot(
Map<String, dynamic> snapshot,
String status,
) {
final task = _castMap(snapshot['task']);
final error = _castMap(snapshot['error']);
final message = _firstNonEmptyDisplayText(
<String, dynamic>{...error, ...snapshot, 'taskMessage': task['message']},
const <String>[
'message',
'error',
'errorMessage',
'reason',
'taskMessage',
'code',
],
<String, dynamic>{...error, ...snapshot},
const <String>['message', 'error', 'errorMessage', 'reason', 'code'],
);
final code = _firstNonEmptyDisplayText(
<String, dynamic>{...error, ...snapshot, 'taskCode': task['code']},
const <String>['code', 'errorCode', 'taskCode'],
<String, dynamic>{...error, ...snapshot},
const <String>['code', 'errorCode'],
);
final result = <String, dynamic>{
'success': false,
'status': status,
'turnId': task['turnId']?.toString().trim() ?? '',
'turnId': snapshot['turnId']?.toString().trim() ?? '',
'error': message.isNotEmpty ? message : 'Bridge session ended: $status',
'message': message.isNotEmpty ? message : 'Bridge session ended: $status',
};
@ -416,29 +456,6 @@ class ExternalCodeAgentAcpDesktopTransport
return result;
}
bool _hasArtifactList(Map<String, dynamic> result) {
for (final key in const <String>['artifacts', 'files', 'attachments']) {
if (_listValue(result[key]).isNotEmpty) {
return true;
}
final recordItems = _listValue(_castMap(result[key])['items']);
if (recordItems.isNotEmpty) {
return true;
}
}
for (final key in const <String>['payload', 'result', 'data']) {
final nested = _castMap(result[key]);
if (nested.isNotEmpty && _hasArtifactList(nested)) {
return true;
}
}
return false;
}
List<Object?> _listValue(Object? value) {
return value is List ? value : const <Object?>[];
}
String _firstNonEmptyDisplayText(
Map<String, dynamic> values,
List<String> keys,

View File

@ -1457,130 +1457,12 @@ class GatewayAcpRuntimeSessionClient implements GatewayRuntimeSessionClient {
}
}
bool _isOpenClawTaskSubmitMethod(String method) {
final normalized = method.trim();
return normalized == 'session.start' || normalized == 'session.message';
}
Duration gatewayAcpHttpResponseTimeoutFor(
Uri endpoint,
String method, [
Map<String, dynamic> params = const <String, dynamic>{},
]) {
if (!_isOpenClawTaskSubmitMethod(method)) {
return const Duration(seconds: 120);
}
return Duration(minutes: gatewayAcpTaskRuntimeBudgetMinutesForParams(params));
}
int gatewayAcpTaskRuntimeBudgetMinutesForParams(Map<String, dynamic> params) {
if (_looksLikeLongArtifactTask(params)) {
return 30;
}
if (_looksLikeGatewayTask(params)) {
return 10;
}
return 2;
}
bool _looksLikeGatewayTask(Map<String, dynamic> params) {
final target = _paramText(params, const <String>[
'requestedExecutionTarget',
'executionTarget',
]).toLowerCase();
if (target == AssistantExecutionTarget.gateway.promptValue) {
return true;
}
final providerText = _paramText(params, const <String>[
'provider',
'gatewayProvider',
'preferredGatewayProviderId',
]).toLowerCase();
if (providerText.contains('openclaw')) {
return true;
}
final routing = params['routing'];
if (routing is Map) {
final preferred = routing['preferredGatewayProviderId']
?.toString()
.trim()
.toLowerCase();
return preferred == kCanonicalGatewayProviderId ||
preferred?.contains('openclaw') == true;
}
return false;
}
bool _looksLikeLongArtifactTask(Map<String, dynamic> params) {
final prompt = _paramText(params, const <String>[
'taskPrompt',
'prompt',
'message',
]);
final lower = prompt.toLowerCase();
final attachments =
_paramListLength(params['attachments']) +
_paramListLength(params['inlineAttachments']);
if (attachments >= 2 || prompt.length >= 1200) {
return true;
}
const markers = <String>[
'生成文件',
'同步生成文件',
'产物',
'附件',
'图片提示词',
'完整调研ppt',
'markdown格式',
'输出markdown',
'输出 完整',
'ppt',
'pptx',
'powerpoint',
'xls',
'.xls',
'word格式',
'word 格式',
'word',
'docx',
'.docx',
'png',
'.png',
'mp4',
'.mp4',
'jpg',
'.jpg',
'markdown格式',
'markdown',
'.md',
'javascript',
'.js',
'image prompt',
'artifacts',
'downloadurl',
];
return markers.any(lower.contains);
}
String _paramText(Map<String, dynamic> params, List<String> keys) {
for (final key in keys) {
final value = params[key];
if (value == null) {
continue;
}
final text = value.toString().trim();
if (text.isNotEmpty) {
return text;
}
}
return '';
}
int _paramListLength(Object? value) {
if (value is List) {
return value.length;
}
return 0;
return const Duration(seconds: 120);
}
class _GatewayAcpRpcRequest {

View File

@ -494,6 +494,27 @@ class GoTaskServiceResult {
String get code => raw['code']?.toString().trim() ?? '';
bool get isOpenClawRunningTaskHandle {
final normalizedStatus = status.trim().toLowerCase();
final runId = raw['runId']?.toString().trim() ?? '';
final artifactScope = raw['artifactScope']?.toString().trim() ?? '';
final provider =
raw['resolvedGatewayProviderId']?.toString().trim().toLowerCase() ??
raw['gatewayProviderId']?.toString().trim().toLowerCase() ??
'';
return normalizedStatus == 'running' &&
runId.isNotEmpty &&
artifactScope.isNotEmpty &&
provider.contains('openclaw');
}
OpenClawTaskAssociation? get openClawTaskAssociation {
if (!isOpenClawRunningTaskHandle) {
return null;
}
return OpenClawTaskAssociation.fromJsonOrNull(raw);
}
String get resolvedExecutionTarget =>
raw['resolvedExecutionTarget']?.toString().trim() ?? '';
@ -651,10 +672,17 @@ abstract class ExternalCodeAgentAcpTransport {
required void Function(GoTaskServiceUpdate update) onUpdate,
});
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
});
Future<void> cancelTask({
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
});
Future<void> closeTask({
@ -683,11 +711,18 @@ abstract class GoTaskServiceClient {
required void Function(GoTaskServiceUpdate update) onUpdate,
});
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
});
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
});
Future<void> closeTask({

View File

@ -37,16 +37,29 @@ class DesktopGoTaskService implements GoTaskServiceClient {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) => _acpTransport.executeTask(request, onUpdate: onUpdate);
@override
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
}) => _acpTransport.getTask(
target: target,
association: association,
route: route,
);
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
}) => _acpTransport.cancelTask(
target: target,
sessionId: sessionId,
threadId: threadId,
association: association,
);
@override

View File

@ -677,6 +677,7 @@ class ThreadContextState {
this.lastArtifactSyncAtMs,
this.lastArtifactSyncStatus,
this.lastTaskArtifactRelativePaths = const <String>[],
this.openClawTaskAssociation,
});
final List<GatewayChatMessage> messages;
@ -694,6 +695,7 @@ class ThreadContextState {
final double? lastArtifactSyncAtMs;
final String? lastArtifactSyncStatus;
final List<String> lastTaskArtifactRelativePaths;
final OpenClawTaskAssociation? openClawTaskAssociation;
ThreadContextState copyWith({
List<GatewayChatMessage>? messages,
@ -712,6 +714,8 @@ class ThreadContextState {
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
bool clearOpenClawTaskAssociation = false,
}) {
return ThreadContextState(
messages: messages ?? this.messages,
@ -738,6 +742,9 @@ class ThreadContextState {
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths == null
? this.lastTaskArtifactRelativePaths
: _stringListFromJson(lastTaskArtifactRelativePaths),
openClawTaskAssociation: clearOpenClawTaskAssociation
? null
: (openClawTaskAssociation ?? this.openClawTaskAssociation),
);
}
@ -758,6 +765,7 @@ class ThreadContextState {
'lastArtifactSyncAtMs': lastArtifactSyncAtMs,
'lastArtifactSyncStatus': lastArtifactSyncStatus,
'lastTaskArtifactRelativePaths': lastTaskArtifactRelativePaths,
'openClawTaskAssociation': openClawTaskAssociation?.toJson(),
};
}
@ -823,6 +831,163 @@ class ThreadContextState {
lastTaskArtifactRelativePaths: _stringListFromJson(
json['lastTaskArtifactRelativePaths'],
),
openClawTaskAssociation: OpenClawTaskAssociation.fromJsonOrNull(
json['openClawTaskAssociation'],
),
);
}
}
class OpenClawTaskAssociation {
const OpenClawTaskAssociation({
required this.sessionId,
required this.threadId,
required this.turnId,
required this.runId,
required this.artifactScope,
required this.artifactDirectory,
required this.gatewayProviderId,
required this.runtimeBudgetMinutes,
required this.startedAtMs,
required this.status,
this.taskLoadClass = '',
this.sessionKey = '',
this.requiredArtifactExtensions = const <String>[],
this.expectedArtifactExtensions = const <String>[],
});
final String sessionId;
final String threadId;
final String turnId;
final String runId;
final String artifactScope;
final String artifactDirectory;
final String gatewayProviderId;
final int runtimeBudgetMinutes;
final double startedAtMs;
final String status;
final String taskLoadClass;
final String sessionKey;
final List<String> requiredArtifactExtensions;
final List<String> expectedArtifactExtensions;
bool get isTerminal {
final normalized = status.trim().toLowerCase();
return normalized == 'completed' ||
normalized == 'failed' ||
normalized == 'cancelled' ||
normalized == 'canceled';
}
OpenClawTaskAssociation copyWith({String? status}) {
return OpenClawTaskAssociation(
sessionId: sessionId,
threadId: threadId,
turnId: turnId,
runId: runId,
artifactScope: artifactScope,
artifactDirectory: artifactDirectory,
gatewayProviderId: gatewayProviderId,
runtimeBudgetMinutes: runtimeBudgetMinutes,
startedAtMs: startedAtMs,
status: status ?? this.status,
taskLoadClass: taskLoadClass,
sessionKey: sessionKey,
requiredArtifactExtensions: requiredArtifactExtensions,
expectedArtifactExtensions: expectedArtifactExtensions,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'sessionId': sessionId,
'threadId': threadId,
'turnId': turnId,
'runId': runId,
'artifactScope': artifactScope,
'artifactDirectory': artifactDirectory,
'gatewayProviderId': gatewayProviderId,
'runtimeBudgetMinutes': runtimeBudgetMinutes,
'startedAtMs': startedAtMs,
'status': status,
'taskLoadClass': taskLoadClass,
'sessionKey': sessionKey,
'requiredArtifactExtensions': requiredArtifactExtensions,
'expectedArtifactExtensions': expectedArtifactExtensions,
};
}
Map<String, dynamic> toTaskGetParams() {
return <String, dynamic>{
'sessionId': sessionId,
'threadId': threadId,
'turnId': turnId,
'runId': runId,
'artifactScope': artifactScope,
'artifactDirectory': artifactDirectory,
'gatewayProviderId': gatewayProviderId,
'runtimeBudgetMinutes': runtimeBudgetMinutes,
'taskLoadClass': taskLoadClass,
'sessionKey': sessionKey,
'requiredArtifactExtensions': requiredArtifactExtensions,
'expectedArtifactExtensions': expectedArtifactExtensions,
};
}
static OpenClawTaskAssociation? fromJsonOrNull(Object? value) {
if (value is! Map) {
return null;
}
final json = value.cast<String, dynamic>();
final runId = json['runId']?.toString().trim() ?? '';
final artifactScope = json['artifactScope']?.toString().trim() ?? '';
if (runId.isEmpty || artifactScope.isEmpty) {
return null;
}
int asInt(Object? raw) {
if (raw is int) {
return raw;
}
if (raw is num) {
return raw.toInt();
}
return int.tryParse(raw?.toString() ?? '') ?? 60;
}
double asDouble(Object? raw) {
if (raw is num) {
return raw.toDouble();
}
return double.tryParse(raw?.toString() ?? '') ?? 0;
}
return OpenClawTaskAssociation(
sessionId: json['sessionId']?.toString().trim() ?? '',
threadId: json['threadId']?.toString().trim() ?? '',
turnId: json['turnId']?.toString().trim() ?? '',
runId: runId,
artifactScope: artifactScope,
artifactDirectory: json['artifactDirectory']?.toString().trim() ?? '',
gatewayProviderId:
json['gatewayProviderId']?.toString().trim().isNotEmpty == true
? json['gatewayProviderId'].toString().trim()
: (json['resolvedGatewayProviderId']?.toString().trim().isNotEmpty ==
true
? json['resolvedGatewayProviderId'].toString().trim()
: 'openclaw'),
runtimeBudgetMinutes: asInt(json['runtimeBudgetMinutes']),
startedAtMs: asDouble(json['startedAtMs']),
status: json['status']?.toString().trim().isNotEmpty == true
? json['status'].toString().trim()
: 'running',
taskLoadClass: json['taskLoadClass']?.toString().trim() ?? '',
sessionKey: json['sessionKey']?.toString().trim() ?? '',
requiredArtifactExtensions: _stringListFromJson(
json['requiredArtifactExtensions'],
),
expectedArtifactExtensions: _stringListFromJson(
json['expectedArtifactExtensions'],
),
);
}
}
@ -931,6 +1096,7 @@ class TaskThread {
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
}) : threadId = _resolveThreadId(threadId),
title = title ?? '',
ownerScope =
@ -977,6 +1143,7 @@ class TaskThread {
lastTaskArtifactRelativePaths: _stringListFromJson(
lastTaskArtifactRelativePaths,
),
openClawTaskAssociation: openClawTaskAssociation,
),
lifecycleState =
lifecycleState ??
@ -1015,6 +1182,8 @@ class TaskThread {
String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus;
List<String> get lastTaskArtifactRelativePaths =>
contextState.lastTaskArtifactRelativePaths;
OpenClawTaskAssociation? get openClawTaskAssociation =>
contextState.openClawTaskAssociation;
String get latestResolvedRuntimeModel =>
contextState.latestResolvedRuntimeModel;
String get latestResolvedProviderId => contextState.latestResolvedProviderId;
@ -1058,6 +1227,8 @@ class TaskThread {
double? lastArtifactSyncAtMs,
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
bool clearOpenClawTaskAssociation = false,
}) {
return TaskThread(
threadId: threadId ?? this.threadId,
@ -1081,6 +1252,8 @@ class TaskThread {
lastArtifactSyncAtMs: lastArtifactSyncAtMs,
lastArtifactSyncStatus: lastArtifactSyncStatus,
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths,
openClawTaskAssociation: openClawTaskAssociation,
clearOpenClawTaskAssociation: clearOpenClawTaskAssociation,
),
lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith(
archived: archived,
@ -1197,6 +1370,7 @@ class TaskThread {
'lastArtifactSyncAtMs': json['lastArtifactSyncAtMs'],
'lastArtifactSyncStatus': json['lastArtifactSyncStatus'],
'lastTaskArtifactRelativePaths': json['lastTaskArtifactRelativePaths'],
'openClawTaskAssociation': json['openClawTaskAssociation'],
};
}

View File

@ -3615,7 +3615,7 @@ void main() {
'openclaw-second-task',
);
await secondSubmitFuture;
expect(controller.openClawGatewayActiveTasksInternal, 0);
await _waitForOpenClawActiveTaskCount(controller, 0);
expect(
controller.chatMessages.map((message) => message.text),
contains('second task completed'),
@ -4083,6 +4083,20 @@ Future<void> _selectGatewaySession(
);
}
Future<void> _waitForOpenClawActiveTaskCount(
AppController controller,
int expected,
) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(deadline)) {
if (controller.openClawGatewayActiveTasksInternal == expected) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
expect(controller.openClawGatewayActiveTasksInternal, expected);
}
Future<List<String>> _startOpenClawActiveTasks(
AppController controller,
_BlockingGoTaskServiceClient fakeGoTaskService, {
@ -4217,12 +4231,36 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
);
}
@override
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
}) async {
return GoTaskServiceResult(
success: true,
message: 'ok',
turnId: association.turnId,
raw: <String, dynamic>{
'success': true,
'status': 'completed',
'turnId': association.turnId,
'runId': association.runId,
'output': 'ok',
},
errorMessage: '',
resolvedModel: '',
route: route,
);
}
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
}) async {}
@override
@ -4275,6 +4313,29 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient {
return completer.future;
}
@override
Future<GoTaskServiceResult> getTask({
required AssistantExecutionTarget target,
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
}) async {
return GoTaskServiceResult(
success: true,
message: 'cleanup',
turnId: association.turnId,
raw: <String, dynamic>{
'success': true,
'status': 'completed',
'turnId': association.turnId,
'runId': association.runId,
'output': 'cleanup',
},
errorMessage: '',
resolvedModel: '',
route: route,
);
}
Future<void> waitForRequestCount(int count) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (requests.length < count && DateTime.now().isBefore(deadline)) {
@ -4338,6 +4399,7 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient {
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
OpenClawTaskAssociation? association,
}) async {
cancelledSessionIds.add(sessionId);
}

View File

@ -253,38 +253,6 @@ void main() {
});
});
group('Gateway ACP task runtime budget', () {
test(
'treats explicit document and media formats as long artifact tasks',
() {
expect(
gatewayAcpTaskRuntimeBudgetMinutesForParams(const <String, dynamic>{
'taskPrompt': '围绕上述话题输出一篇800-1000字左右的文章 markdown格式',
}),
30,
);
expect(
gatewayAcpTaskRuntimeBudgetMinutesForParams(const <String, dynamic>{
'taskPrompt': 'save the final article as article.docx',
}),
30,
);
expect(
gatewayAcpTaskRuntimeBudgetMinutesForParams(const <String, dynamic>{
'taskPrompt': '生成封面图 png 和短视频 mp4',
}),
30,
);
expect(
gatewayAcpTaskRuntimeBudgetMinutesForParams(const <String, dynamic>{
'taskPrompt': '导出 jpg、pptx 和 xls 文件',
}),
30,
);
},
);
});
group('GatewayAcpClient authorization', () {
test('normalizes raw resolver token into bearer header for HTTP', () async {
final capture = await _startAcpHttpServer();
@ -672,7 +640,7 @@ void main() {
);
test(
'recovers OpenClaw task result from bridge session snapshot after SSE connection close',
'recovers OpenClaw task result from bridge task snapshot after SSE connection close',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
@ -701,7 +669,7 @@ void main() {
socket.destroy();
return;
}
if (method == 'xworkmate.sessions.get') {
if (method == 'xworkmate.tasks.get') {
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
@ -717,7 +685,7 @@ void main() {
},
'result': <String, dynamic>{
'success': true,
'output': 'recovered from bridge session snapshot',
'output': 'recovered from bridge task snapshot',
'turnId': 'turn-recovered',
},
'artifacts': <String, dynamic>{
@ -772,7 +740,7 @@ void main() {
);
expect(result.success, isTrue);
expect(result.message, 'recovered from bridge session snapshot');
expect(result.message, 'recovered from bridge task snapshot');
expect(result.artifacts.single.relativePath, 'exports/snapshot.md');
expect(result.remoteWorkingDirectory, '/remote/openclaw/workspace');
expect(result.remoteWorkspaceRefKind, WorkspaceRefKind.remotePath);
@ -818,7 +786,7 @@ void main() {
socket.destroy();
return;
}
if (method == 'xworkmate.sessions.get') {
if (method == 'xworkmate.tasks.get') {
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
@ -890,13 +858,13 @@ void main() {
expect(result.artifacts.single.relativePath, 'reports/final.md');
expect(requestMethods, <String>[
'session.start',
'xworkmate.sessions.get',
'xworkmate.tasks.get',
]);
},
);
test(
'recovers OpenClaw follow-up from bridge session snapshot after SSE ends without final envelope',
'recovers OpenClaw follow-up from bridge task snapshot after SSE ends without final envelope',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
@ -921,7 +889,7 @@ void main() {
await request.response.close();
return;
}
if (method == 'xworkmate.sessions.get') {
if (method == 'xworkmate.tasks.get') {
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
@ -1014,7 +982,7 @@ void main() {
socket.destroy();
return;
}
if (method == 'xworkmate.sessions.get') {
if (method == 'xworkmate.tasks.get') {
snapshotPolls += 1;
final completed = snapshotPolls >= 3;
request.response.headers.contentType = ContentType.json;
@ -1080,96 +1048,6 @@ void main() {
},
);
test(
'uses long task budget for default OpenClaw SSE recovery polling',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
var snapshotPolls = 0;
server.listen((request) async {
final body = await utf8.decoder.bind(request).join();
final decoded = jsonDecode(body) as Map<String, dynamic>;
final method = decoded['method']?.toString() ?? '';
final id = decoded['id']?.toString() ?? 'request-id';
if (method == 'session.start') {
final event = jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'xworkmate.bridge.accepted',
'params': <String, dynamic>{'sessionId': 'unit-fixture-task-d'},
});
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'text/event-stream',
);
request.response.write('data: $event\n\n');
await request.response.close();
return;
}
if (method == 'xworkmate.sessions.get') {
snapshotPolls += 1;
final completed = snapshotPolls >= 301;
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'status': completed ? 'completed' : 'running',
'sessionId': 'unit-fixture-task-d',
'threadId': 'unit-fixture-task-d',
'task': <String, dynamic>{
'state': completed ? 'completed' : 'running',
'turnId': 'turn-recovered-long',
},
if (completed)
'result': <String, dynamic>{
'success': true,
'output': 'recovered after long polling window',
'turnId': 'turn-recovered-long',
},
},
}),
);
await request.response.close();
return;
}
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
});
final endpoint = Uri.parse('http://127.0.0.1:${server.port}');
final transport = ExternalCodeAgentAcpDesktopTransport(
client: GatewayAcpClient(endpointResolver: () => endpoint),
endpointResolver: (_) => endpoint,
taskEndpointResolver: (_) => endpoint,
recoveryPollDelay: const Duration(microseconds: 1),
);
addTearDown(transport.dispose);
final result = await transport.executeTask(
const GoTaskServiceRequest(
sessionId: 'unit-fixture-task-d',
threadId: 'unit-fixture-task-d',
target: AssistantExecutionTarget.gateway,
provider: SingleAgentProvider.openclaw,
prompt: '生成封面图 png 和短视频 mp4',
workingDirectory: '/tmp/workspace',
model: '',
thinking: 'off',
selectedSkills: <String>[],
inlineAttachments: <GatewayChatAttachmentPayload>[],
localAttachments: <CollaborationAttachment>[],
agentId: '',
metadata: <String, dynamic>{},
),
onUpdate: (_) {},
);
expect(snapshotPolls, 301);
expect(result.success, isTrue);
expect(result.message, 'recovered after long polling window');
},
);
test(
'recovers terminal failed OpenClaw snapshot without displayable result',
() async {
@ -1198,7 +1076,7 @@ void main() {
socket.destroy();
return;
}
if (method == 'xworkmate.sessions.get') {
if (method == 'xworkmate.tasks.get') {
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
@ -1957,7 +1835,7 @@ void main() {
},
);
test('task submit uses dynamic HTTP response timeout budgets', () {
test('task submit uses short HTTP response timeout', () {
final openClawEndpoint = Uri.parse(
'https://xworkmate-bridge.svc.plus/acp/rpc',
);
@ -1971,7 +1849,7 @@ void main() {
'session.start',
const <String, dynamic>{'requestedExecutionTarget': 'gateway'},
),
const Duration(minutes: 10),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(
@ -1982,11 +1860,11 @@ void main() {
'requestedExecutionTarget': 'gateway',
},
),
const Duration(minutes: 30),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(acpEndpoint, 'session.start'),
const Duration(minutes: 2),
const Duration(seconds: 120),
);
expect(
gatewayAcpHttpResponseTimeoutFor(openClawEndpoint, 'acp.capabilities'),